| 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,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 60 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 60 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 61 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | 61 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 62 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | 62 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; |
| 63 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | ||
| 63 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); | 64 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 64 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); | 65 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 65 | const DESKTOP_SCHEME = "t3"; | 66 | const DESKTOP_SCHEME = "t3"; |
| ... | @@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void { | ... | @@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void { |
| 1172 | event.returnValue = backendWsUrl; | 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 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); | 1184 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1176 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { | 1185 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { |
| 1177 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; | 1186 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
apps/desktop/src/preload.ts+8| ... | @@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | ... | @@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 15 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | 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 | contextBridge.exposeInMainWorld("desktopBridge", { | 18 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 18 | getWsUrl: () => { | 19 | getWsUrl: () => { |
| 19 | const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); | 20 | const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); |
| 20 | return typeof result === "string" ? result : null; | 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 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), | 30 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), |
| 23 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), | 31 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 24 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), | 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,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; |
| 45 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; | 45 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; |
| 46 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; | 46 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; |
| 47 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; | 47 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; |
| 48 | import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; | ||
| 48 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; | 49 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; |
| 49 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; | 50 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; |
| 50 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; | 51 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; |
| ... | @@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 338 | Layer.provideMerge(runtimeServicesLayer), | 339 | Layer.provideMerge(runtimeServicesLayer), |
| 339 | Layer.provideMerge(orchestrationReactorLayer), | 340 | Layer.provideMerge(orchestrationReactorLayer), |
| 340 | Layer.provide(persistenceLayer), | 341 | Layer.provide(persistenceLayer), |
| 342 | Layer.provideMerge(RepositoryIdentityResolverLive), | ||
| 341 | Layer.provideMerge(ServerSettingsService.layerTest()), | 343 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 342 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), | 344 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), |
| 343 | Layer.provideMerge(NodeServices.layer), | 345 | Layer.provideMerge(NodeServices.layer), |
apps/server/src/config.ts+2| ... | @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { | ... | @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { |
| 30 | readonly providerEventLogPath: string; | 30 | readonly providerEventLogPath: string; |
| 31 | readonly terminalLogsDir: string; | 31 | readonly terminalLogsDir: string; |
| 32 | readonly anonymousIdPath: string; | 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,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 83 | providerEventLogPath: join(providerLogsDir, "events.log"), | 84 | providerEventLogPath: join(providerLogsDir, "events.log"), |
| 84 | terminalLogsDir: join(logsDir, "terminals"), | 85 | terminalLogsDir: join(logsDir, "terminals"), |
| 85 | anonymousIdPath: join(stateDir, "anonymous-id"), | 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,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 854 | "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | 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 | it.effect( | 860 | it.effect( |
| ... | @@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 962 | ), | 962 | ), |
| 963 | ).toBe(false); | 963 | ).toBe(false); |
| 964 | }), | 964 | }), |
| 965 | 12_000, | 965 | 20_000, |
| 966 | ); | 966 | ); |
| 967 | 967 | ||
| 968 | it.effect("status returns merged PR state when latest PR was merged", () => | 968 | it.effect("status returns merged PR state when latest PR was merged", () => |
| ... | @@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1685 | false, | 1685 | false, |
| 1686 | ); | 1686 | ); |
| 1687 | }), | 1687 | }), |
| 1688 | 12_000, | 1688 | 20_000, |
| 1689 | ); | 1689 | ); |
| 1690 | 1690 | ||
| 1691 | it.effect( | 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,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; |
| 20 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; | 20 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; |
| 21 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | 21 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 22 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; | 22 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; |
| 23 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | ||
| 23 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; | 24 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 24 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 25 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 25 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 26 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| ... | @@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { | ... | @@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { |
| 256 | Layer.provide(OrchestrationProjectionPipelineLive), | 257 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 257 | Layer.provide(OrchestrationEventStoreLive), | 258 | Layer.provide(OrchestrationEventStoreLive), |
| 258 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 259 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 260 | Layer.provide(RepositoryIdentityResolverLive), | ||
| 259 | Layer.provide(SqlitePersistenceMemory), | 261 | Layer.provide(SqlitePersistenceMemory), |
| 260 | ); | 262 | ); |
| 261 | 263 |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | OrchestrationEventStore, | 19 | OrchestrationEventStore, |
| 20 | type OrchestrationEventStoreShape, | 20 | type OrchestrationEventStoreShape, |
| 21 | } from "../../persistence/Services/OrchestrationEventStore.ts"; | 21 | } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 22 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | ||
| 22 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 23 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 24 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 25 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| ... | @@ -45,6 +46,7 @@ async function createOrchestrationSystem() { | ... | @@ -45,6 +46,7 @@ async function createOrchestrationSystem() { |
| 45 | Layer.provide(OrchestrationProjectionPipelineLive), | 46 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 46 | Layer.provide(OrchestrationEventStoreLive), | 47 | Layer.provide(OrchestrationEventStoreLive), |
| 47 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 48 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 49 | Layer.provide(RepositoryIdentityResolverLive), | ||
| 48 | Layer.provide(SqlitePersistenceMemory), | 50 | Layer.provide(SqlitePersistenceMemory), |
| 49 | Layer.provideMerge(ServerConfigLayer), | 51 | Layer.provideMerge(ServerConfigLayer), |
| 50 | Layer.provideMerge(NodeServices.layer), | 52 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { |
| 623 | Layer.provide(OrchestrationProjectionPipelineLive), | 625 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 624 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), | 626 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 625 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 627 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 628 | Layer.provide(RepositoryIdentityResolverLive), | ||
| 626 | Layer.provide(SqlitePersistenceMemory), | 629 | Layer.provide(SqlitePersistenceMemory), |
| 627 | Layer.provideMerge(ServerConfigLayer), | 630 | Layer.provideMerge(ServerConfigLayer), |
| 628 | Layer.provideMerge(NodeServices.layer), | 631 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { |
| 719 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), | 722 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 720 | Layer.provide(OrchestrationEventStoreLive), | 723 | Layer.provide(OrchestrationEventStoreLive), |
| 721 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 724 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 725 | Layer.provide(RepositoryIdentityResolverLive), | ||
| 722 | Layer.provide(SqlitePersistenceMemory), | 726 | Layer.provide(SqlitePersistenceMemory), |
| 723 | ), | 727 | ), |
| 724 | ); | 728 | ); |
| ... | @@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { |
| 861 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), | 865 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 862 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), | 866 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), |
| 863 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 867 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 868 | Layer.provide(RepositoryIdentityResolverLive), | ||
| 864 | Layer.provide(SqlitePersistenceMemory), | 869 | Layer.provide(SqlitePersistenceMemory), |
| 865 | ), | 870 | ), |
| 866 | ); | 871 | ); |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | SqlitePersistenceMemory, | 20 | SqlitePersistenceMemory, |
| 21 | } from "../../persistence/Layers/Sqlite.ts"; | 21 | } from "../../persistence/Layers/Sqlite.ts"; |
| 22 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | 22 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 23 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | ||
| 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 24 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | import { | 25 | import { |
| 25 | ORCHESTRATION_PROJECTOR_NAMES, | 26 | ORCHESTRATION_PROJECTOR_NAMES, |
| ... | @@ -1846,6 +1847,7 @@ const engineLayer = it.layer( | ... | @@ -1846,6 +1847,7 @@ const engineLayer = it.layer( |
| 1846 | Layer.provide(OrchestrationProjectionPipelineLive), | 1847 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 1847 | Layer.provide(OrchestrationEventStoreLive), | 1848 | Layer.provide(OrchestrationEventStoreLive), |
| 1848 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 1849 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 1850 | Layer.provide(RepositoryIdentityResolverLive), | ||
| 1849 | Layer.provideMerge(SqlitePersistenceMemory), | 1851 | Layer.provideMerge(SqlitePersistenceMemory), |
| 1850 | Layer.provideMerge( | 1852 | Layer.provideMerge( |
| 1851 | ServerConfig.layerTest(process.cwd(), { | 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,6 +4,7 @@ import { Effect, Layer } from "effect"; |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 5 | 5 | ||
| 6 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 6 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 7 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | ||
| 7 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | 8 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 8 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 9 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 9 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | 10 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| ... | @@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | ... | @@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 15 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | 16 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); |
| 16 | 17 | ||
| 17 | const projectionSnapshotLayer = it.layer( | 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 | projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | 25 | projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| ... | @@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 234 | id: asProjectId("project-1"), | 238 | id: asProjectId("project-1"), |
| 235 | title: "Project 1", | 239 | title: "Project 1", |
| 236 | workspaceRoot: "/tmp/project-1", | 240 | workspaceRoot: "/tmp/project-1", |
| 241 | repositoryIdentity: null, | ||
| 237 | defaultModelSelection: { | 242 | defaultModelSelection: { |
| 238 | provider: "codex", | 243 | provider: "codex", |
| 239 | model: "gpt-5-codex", | 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,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh |
| 38 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; | 38 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; |
| 39 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; | 39 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 40 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; | 40 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; |
| 41 | import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; | ||
| 41 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | 42 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 42 | import { | 43 | import { |
| 43 | ProjectionSnapshotQuery, | 44 | ProjectionSnapshotQuery, |
| ... | @@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st | ... | @@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st |
| 163 | 164 | ||
| 164 | const makeProjectionSnapshotQuery = Effect.gen(function* () { | 165 | const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 165 | const sql = yield* SqlClient.SqlClient; | 166 | const sql = yield* SqlClient.SqlClient; |
| 167 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | ||
| 168 | const repositoryIdentityResolutionConcurrency = 4; | ||
| 166 | 169 | ||
| 167 | const listProjectRows = SqlSchema.findAll({ | 170 | const listProjectRows = SqlSchema.findAll({ |
| 168 | Request: Schema.Void, | 171 | Request: Schema.Void, |
| ... | @@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 436 | const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => | 439 | const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => |
| 437 | sql | 440 | sql |
| 438 | .withTransaction( | 441 | .withTransaction( |
| 439 | Effect.gen(function* () { | 442 | Effect.all([ |
| 440 | const [ | 443 | listProjectRows(undefined).pipe( |
| 441 | projectRows, | 444 | Effect.mapError( |
| 442 | threadRows, | 445 | toPersistenceSqlOrDecodeError( |
| 443 | messageRows, | 446 | "ProjectionSnapshotQuery.getSnapshot:listProjects:query", |
| 444 | proposedPlanRows, | 447 | "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", |
| 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 | ), | ||
| 465 | ), | 448 | ), |
| 466 | ), | 449 | ), |
| 467 | listThreadMessageRows(undefined).pipe( | 450 | ), |
| 468 | Effect.mapError( | 451 | listThreadRows(undefined).pipe( |
| 469 | toPersistenceSqlOrDecodeError( | 452 | Effect.mapError( |
| 470 | "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", | 453 | toPersistenceSqlOrDecodeError( |
| 471 | "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", | 454 | "ProjectionSnapshotQuery.getSnapshot:listThreads:query", |
| 472 | ), | 455 | "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", |
| 473 | ), | 456 | ), |
| 474 | ), | 457 | ), |
| 475 | listThreadProposedPlanRows(undefined).pipe( | 458 | ), |
| 476 | Effect.mapError( | 459 | listThreadMessageRows(undefined).pipe( |
| 477 | toPersistenceSqlOrDecodeError( | 460 | Effect.mapError( |
| 478 | "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", | 461 | toPersistenceSqlOrDecodeError( |
| 479 | "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", | 462 | "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", |
| 480 | ), | 463 | "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", |
| 481 | ), | 464 | ), |
| 482 | ), | 465 | ), |
| 483 | listThreadActivityRows(undefined).pipe( | 466 | ), |
| 484 | Effect.mapError( | 467 | listThreadProposedPlanRows(undefined).pipe( |
| 485 | toPersistenceSqlOrDecodeError( | 468 | Effect.mapError( |
| 486 | "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", | 469 | toPersistenceSqlOrDecodeError( |
| 487 | "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", | 470 | "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", |
| 488 | ), | 471 | "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", |
| 489 | ), | 472 | ), |
| 490 | ), | 473 | ), |
| 491 | listThreadSessionRows(undefined).pipe( | 474 | ), |
| 492 | Effect.mapError( | 475 | listThreadActivityRows(undefined).pipe( |
| 493 | toPersistenceSqlOrDecodeError( | 476 | Effect.mapError( |
| 494 | "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", | 477 | toPersistenceSqlOrDecodeError( |
| 495 | "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", | 478 | "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", |
| 496 | ), | 479 | "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", |
| 497 | ), | 480 | ), |
| 498 | ), | 481 | ), |
| 499 | listCheckpointRows(undefined).pipe( | 482 | ), |
| 500 | Effect.mapError( | 483 | listThreadSessionRows(undefined).pipe( |
| 501 | toPersistenceSqlOrDecodeError( | 484 | Effect.mapError( |
| 502 | "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", | 485 | toPersistenceSqlOrDecodeError( |
| 503 | "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", | 486 | "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", |
| 504 | ), | 487 | "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", |
| 505 | ), | 488 | ), |
| 506 | ), | 489 | ), |
| 507 | listLatestTurnRows(undefined).pipe( | 490 | ), |
| 508 | Effect.mapError( | 491 | listCheckpointRows(undefined).pipe( |
| 509 | toPersistenceSqlOrDecodeError( | 492 | Effect.mapError( |
| 510 | "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", | 493 | toPersistenceSqlOrDecodeError( |
| 511 | "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", | 494 | "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", |
| 512 | ), | 495 | "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", |
| 513 | ), | 496 | ), |
| 514 | ), | 497 | ), |
| 515 | listProjectionStateRows(undefined).pipe( | 498 | ), |
| 516 | Effect.mapError( | 499 | listLatestTurnRows(undefined).pipe( |
| 517 | toPersistenceSqlOrDecodeError( | 500 | Effect.mapError( |
| 518 | "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", | 501 | toPersistenceSqlOrDecodeError( |
| 519 | "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", | 502 | "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", |
| 520 | ), | 503 | "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", |
| 521 | ), | 504 | ), |
| 522 | ), | 505 | ), |
| 523 | ]); | 506 | ), |
| 524 | 507 | listProjectionStateRows(undefined).pipe( | |
| 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( | ||
| 695 | Effect.mapError( | 508 | Effect.mapError( |
| 696 | toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), | 509 | toPersistenceSqlOrDecodeError( |
| 510 | "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", | ||
| 511 | "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", | ||
| 512 | ), | ||
| 697 | ), | 513 | ), |
| 698 | ); | 514 | ), |
| 699 | }), | 515 | ]), |
| 700 | ) | 516 | ) |
| 701 | .pipe( | 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 | Effect.mapError((error) => { | 719 | Effect.mapError((error) => { |
| 703 | if (isPersistenceError(error)) { | 720 | if (isPersistenceError(error)) { |
| 704 | return error; | 721 | return error; |
| ... | @@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 732 | "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", | 749 | "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", |
| 733 | ), | 750 | ), |
| 734 | ), | 751 | ), |
| 735 | Effect.map( | 752 | Effect.flatMap((option) => |
| 736 | Option.map( | 753 | Option.isNone(option) |
| 737 | (row): OrchestrationProject => ({ | 754 | ? Effect.succeed(Option.none<OrchestrationProject>()) |
| 738 | id: row.projectId, | 755 | : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe( |
| 739 | title: row.title, | 756 | Effect.map((repositoryIdentity) => |
| 740 | workspaceRoot: row.workspaceRoot, | 757 | Option.some({ |
| 741 | defaultModelSelection: row.defaultModelSelection, | 758 | id: option.value.projectId, |
| 742 | scripts: row.scripts, | 759 | title: option.value.title, |
| 743 | createdAt: row.createdAt, | 760 | workspaceRoot: option.value.workspaceRoot, |
| 744 | updatedAt: row.updatedAt, | 761 | repositoryIdentity, |
| 745 | deletedAt: row.deletedAt, | 762 | defaultModelSelection: option.value.defaultModelSelection, |
| 746 | }), | 763 | scripts: option.value.scripts, |
| 747 | ), | 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,6 +28,7 @@ import { |
| 28 | } from "../../provider/Services/ProviderService.ts"; | 28 | } from "../../provider/Services/ProviderService.ts"; |
| 29 | import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; | 29 | import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; |
| 30 | import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; | 30 | import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; |
| 31 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | ||
| 31 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 32 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 32 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 33 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 34 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| ... | @@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { |
| 218 | Layer.provide(OrchestrationProjectionPipelineLive), | 219 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 219 | Layer.provide(OrchestrationEventStoreLive), | 220 | Layer.provide(OrchestrationEventStoreLive), |
| 220 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 221 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 222 | Layer.provide(RepositoryIdentityResolverLive), | ||
| 221 | Layer.provide(SqlitePersistenceMemory), | 223 | Layer.provide(SqlitePersistenceMemory), |
| 222 | ); | 224 | ); |
| 223 | const layer = ProviderCommandReactorLive.pipe( | 225 | const layer = ProviderCommandReactorLive.pipe( |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2| ... | @@ -29,6 +29,7 @@ import { | ... | @@ -29,6 +29,7 @@ import { |
| 29 | ProviderService, | 29 | ProviderService, |
| 30 | type ProviderServiceShape, | 30 | type ProviderServiceShape, |
| 31 | } from "../../provider/Services/ProviderService.ts"; | 31 | } from "../../provider/Services/ProviderService.ts"; |
| 32 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | ||
| 32 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 33 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 33 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 34 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 34 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 35 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| ... | @@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 204 | Layer.provide(OrchestrationProjectionPipelineLive), | 205 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 205 | Layer.provide(OrchestrationEventStoreLive), | 206 | Layer.provide(OrchestrationEventStoreLive), |
| 206 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 207 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 208 | Layer.provide(RepositoryIdentityResolverLive), | ||
| 207 | Layer.provide(SqlitePersistenceMemory), | 209 | Layer.provide(SqlitePersistenceMemory), |
| 208 | ); | 210 | ); |
| 209 | const layer = ProviderRuntimeIngestionLive.pipe( | 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,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { | 5 | import { |
| 6 | CommandId, | 6 | CommandId, |
| 7 | DEFAULT_SERVER_SETTINGS, | 7 | DEFAULT_SERVER_SETTINGS, |
| 8 | EnvironmentId, | ||
| 9 | EventId, | ||
| 8 | GitCommandError, | 10 | GitCommandError, |
| 9 | KeybindingRule, | 11 | KeybindingRule, |
| 10 | MessageId, | 12 | MessageId, |
| ... | @@ -83,6 +85,14 @@ import { | ... | @@ -83,6 +85,14 @@ import { |
| 83 | ProjectSetupScriptRunner, | 85 | ProjectSetupScriptRunner, |
| 84 | type ProjectSetupScriptRunnerShape, | 86 | type ProjectSetupScriptRunnerShape, |
| 85 | } from "./project/Services/ProjectSetupScriptRunner.ts"; | 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 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | 96 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; |
| 87 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 97 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 88 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 98 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| ... | @@ -93,6 +103,18 @@ const defaultModelSelection = { | ... | @@ -93,6 +103,18 @@ const defaultModelSelection = { |
| 93 | provider: "codex", | 103 | provider: "codex", |
| 94 | model: "gpt-5-codex", | 104 | model: "gpt-5-codex", |
| 95 | } as const; | 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 | const makeDefaultOrchestrationReadModel = () => { | 119 | const makeDefaultOrchestrationReadModel = () => { |
| 98 | const now = new Date().toISOString(); | 120 | const now = new Date().toISOString(); |
| ... | @@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { | ... | @@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { |
| 270 | browserTraceCollector?: Partial<BrowserTraceCollectorShape>; | 292 | browserTraceCollector?: Partial<BrowserTraceCollectorShape>; |
| 271 | serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>; | 293 | serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>; |
| 272 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; | 294 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; |
| 295 | serverEnvironment?: Partial<ServerEnvironmentShape>; | ||
| 296 | repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>; | ||
| 273 | }; | 297 | }; |
| 274 | }) => | 298 | }) => |
| 275 | Effect.gen(function* () { | 299 | Effect.gen(function* () { |
| ... | @@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { | ... | @@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { |
| 416 | ...options?.layers?.serverRuntimeStartup, | 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 | Layer.provide(workspaceAndProjectServicesLayer), | 456 | Layer.provide(workspaceAndProjectServicesLayer), |
| 420 | Layer.provideMerge(FetchHttpClient.layer), | 457 | Layer.provideMerge(FetchHttpClient.layer), |
| 421 | Layer.provide(layerConfig), | 458 | Layer.provide(layerConfig), |
| ... | @@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1069 | sequence: 1, | 1106 | sequence: 1, |
| 1070 | type: "welcome" as const, | 1107 | type: "welcome" as const, |
| 1071 | payload: { | 1108 | payload: { |
| 1109 | environment: testEnvironmentDescriptor, | ||
| 1072 | cwd: "/tmp/project", | 1110 | cwd: "/tmp/project", |
| 1073 | projectName: "project", | 1111 | projectName: "project", |
| 1074 | }, | 1112 | }, |
| ... | @@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1078 | version: 1 as const, | 1116 | version: 1 as const, |
| 1079 | sequence: 2, | 1117 | sequence: 2, |
| 1080 | type: "ready" as const, | 1118 | type: "ready" as const, |
| 1081 | payload: { at: new Date().toISOString() }, | 1119 | payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor }, |
| 1082 | }); | 1120 | }); |
| 1083 | 1121 | ||
| 1084 | yield* buildAppUnderTest({ | 1122 | yield* buildAppUnderTest({ |
| ... | @@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1996 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("closes thread terminals after a successful archive command", () => | 2104 | it.effect("closes thread terminals after a successful archive command", () => |
| 2000 | Effect.gen(function* () { | 2105 | Effect.gen(function* () { |
| 2001 | const threadId = ThreadId.makeUnsafe("thread-archive"); | 2106 | const threadId = ThreadId.makeUnsafe("thread-archive"); |
| ... | @@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2498 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("routes websocket rpc orchestration.getSnapshot errors", () => | 2745 | it.effect("routes websocket rpc orchestration.getSnapshot errors", () => |
| 2502 | Effect.gen(function* () { | 2746 | Effect.gen(function* () { |
| 2503 | yield* buildAppUnderTest({ | 2747 | yield* buildAppUnderTest({ |
apps/server/src/server.ts+4| ... | @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" | ... | @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" |
| 44 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; | 44 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; |
| 45 | import { ServerSettingsLive } from "./serverSettings"; | 45 | import { ServerSettingsLive } from "./serverSettings"; |
| 46 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; | 46 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; |
| 47 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; | ||
| 47 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; | 48 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; |
| 48 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; | 49 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; |
| 49 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; | 50 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; |
| 50 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; | 51 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; |
| 51 | import { ObservabilityLive } from "./observability/Layers/Observability"; | 52 | import { ObservabilityLive } from "./observability/Layers/Observability"; |
| 53 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; | ||
| 52 | 54 | ||
| 53 | const PtyAdapterLive = Layer.unwrap( | 55 | const PtyAdapterLive = Layer.unwrap( |
| 54 | Effect.gen(function* () { | 56 | Effect.gen(function* () { |
| ... | @@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( |
| 199 | Layer.provideMerge(ServerSettingsLive), | 201 | Layer.provideMerge(ServerSettingsLive), |
| 200 | Layer.provideMerge(WorkspaceLayerLive), | 202 | Layer.provideMerge(WorkspaceLayerLive), |
| 201 | Layer.provideMerge(ProjectFaviconResolverLive), | 203 | Layer.provideMerge(ProjectFaviconResolverLive), |
| 204 | Layer.provideMerge(RepositoryIdentityResolverLive), | ||
| 205 | Layer.provideMerge(ServerEnvironmentLive), | ||
| 202 | 206 | ||
| 203 | // Misc. | 207 | // Misc. |
| 204 | Layer.provideMerge(AnalyticsServiceLayerLive), | 208 | Layer.provideMerge(AnalyticsServiceLayerLive), |
apps/server/src/serverLifecycleEvents.test.ts+10| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 1 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | import { assertTrue } from "@effect/vitest/utils"; | 3 | import { assertTrue } from "@effect/vitest/utils"; |
| 3 | import { Effect, Option } from "effect"; | 4 | import { Effect, Option } from "effect"; |
| ... | @@ -9,12 +10,20 @@ it.effect( | ... | @@ -9,12 +10,20 @@ it.effect( |
| 9 | () => | 10 | () => |
| 10 | Effect.gen(function* () { | 11 | Effect.gen(function* () { |
| 11 | const lifecycleEvents = yield* ServerLifecycleEvents; | 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 | const welcome = yield* lifecycleEvents | 21 | const welcome = yield* lifecycleEvents |
| 14 | .publish({ | 22 | .publish({ |
| 15 | version: 1, | 23 | version: 1, |
| 16 | type: "welcome", | 24 | type: "welcome", |
| 17 | payload: { | 25 | payload: { |
| 26 | environment, | ||
| 18 | cwd: "/tmp/project", | 27 | cwd: "/tmp/project", |
| 19 | projectName: "project", | 28 | projectName: "project", |
| 20 | }, | 29 | }, |
| ... | @@ -29,6 +38,7 @@ it.effect( | ... | @@ -29,6 +38,7 @@ it.effect( |
| 29 | type: "ready", | 38 | type: "ready", |
| 30 | payload: { | 39 | payload: { |
| 31 | at: new Date().toISOString(), | 40 | at: new Date().toISOString(), |
| 41 | environment, | ||
| 32 | }, | 42 | }, |
| 33 | }) | 43 | }) |
| 34 | .pipe(Effect.timeoutOption("50 millis")); | 44 | .pipe(Effect.timeoutOption("50 millis")); |
apps/server/src/serverRuntimeStartup.ts+12-2| ... | @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap | ... | @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap |
| 27 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | 27 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 28 | import { ServerLifecycleEvents } from "./serverLifecycleEvents"; | 28 | import { ServerLifecycleEvents } from "./serverLifecycleEvents"; |
| 29 | import { ServerSettingsService } from "./serverSettings"; | 29 | import { ServerSettingsService } from "./serverSettings"; |
| 30 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | ||
| 30 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | 31 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 31 | 32 | ||
| 32 | const isWildcardHost = (host: string | undefined): boolean => | 33 | const isWildcardHost = (host: string | undefined): boolean => |
| ... | @@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { |
| 262 | const orchestrationReactor = yield* OrchestrationReactor; | 263 | const orchestrationReactor = yield* OrchestrationReactor; |
| 263 | const lifecycleEvents = yield* ServerLifecycleEvents; | 264 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 264 | const serverSettings = yield* ServerSettingsService; | 265 | const serverSettings = yield* ServerSettingsService; |
| 266 | const serverEnvironment = yield* ServerEnvironment; | ||
| 265 | 267 | ||
| 266 | const commandGate = yield* makeCommandGate; | 268 | const commandGate = yield* makeCommandGate; |
| 267 | const httpListening = yield* Deferred.make<void>(); | 269 | const httpListening = yield* Deferred.make<void>(); |
| ... | @@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { |
| 308 | 310 | ||
| 309 | yield* Effect.logDebug("startup phase: preparing welcome payload"); | 311 | yield* Effect.logDebug("startup phase: preparing welcome payload"); |
| 310 | const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); | 312 | const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); |
| 313 | const environment = yield* serverEnvironment.getDescriptor; | ||
| 311 | yield* Effect.logDebug("startup phase: publishing welcome event", { | 314 | yield* Effect.logDebug("startup phase: publishing welcome event", { |
| 315 | environmentId: environment.environmentId, | ||
| 312 | cwd: welcome.cwd, | 316 | cwd: welcome.cwd, |
| 313 | projectName: welcome.projectName, | 317 | projectName: welcome.projectName, |
| 314 | bootstrapProjectId: welcome.bootstrapProjectId, | 318 | bootstrapProjectId: welcome.bootstrapProjectId, |
| ... | @@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { |
| 319 | lifecycleEvents.publish({ | 323 | lifecycleEvents.publish({ |
| 320 | version: 1, | 324 | version: 1, |
| 321 | type: "welcome", | 325 | type: "welcome", |
| 322 | payload: welcome, | 326 | payload: { |
| 327 | environment, | ||
| 328 | ...welcome, | ||
| 329 | }, | ||
| 323 | }), | 330 | }), |
| 324 | ); | 331 | ); |
| 325 | }).pipe( | 332 | }).pipe( |
| ... | @@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { |
| 354 | lifecycleEvents.publish({ | 361 | lifecycleEvents.publish({ |
| 355 | version: 1, | 362 | version: 1, |
| 356 | type: "ready", | 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,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; |
| 47 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; | 47 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; |
| 48 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; | 48 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; |
| 49 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; | 49 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; |
| 50 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; | ||
| 51 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | ||
| 50 | 52 | ||
| 51 | const WsRpcLayer = WsRpcGroup.toLayer( | 53 | const WsRpcLayer = WsRpcGroup.toLayer( |
| 52 | Effect.gen(function* () { | 54 | Effect.gen(function* () { |
| ... | @@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 67 | const workspaceEntries = yield* WorkspaceEntries; | 69 | const workspaceEntries = yield* WorkspaceEntries; |
| 68 | const workspaceFileSystem = yield* WorkspaceFileSystem; | 70 | const workspaceFileSystem = yield* WorkspaceFileSystem; |
| 69 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | 71 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; |
| 72 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | ||
| 73 | const serverEnvironment = yield* ServerEnvironment; | ||
| 70 | const serverCommandId = (tag: string) => | 74 | const serverCommandId = (tag: string) => |
| 71 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | 75 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); |
| 72 | 76 | ||
| ... | @@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -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 | const dispatchBootstrapTurnStart = ( | 163 | const dispatchBootstrapTurnStart = ( |
| 117 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, | 164 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, |
| 118 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => | 165 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => |
| ... | @@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 329 | const keybindingsConfig = yield* keybindings.loadConfigState; | 376 | const keybindingsConfig = yield* keybindings.loadConfigState; |
| 330 | const providers = yield* providerRegistry.getProviders; | 377 | const providers = yield* providerRegistry.getProviders; |
| 331 | const settings = yield* serverSettings.getSettings; | 378 | const settings = yield* serverSettings.getSettings; |
| 379 | const environment = yield* serverEnvironment.getDescriptor; | ||
| 332 | 380 | ||
| 333 | return { | 381 | return { |
| 382 | environment, | ||
| 334 | cwd: config.cwd, | 383 | cwd: config.cwd, |
| 335 | keybindingsConfigPath: config.keybindingsConfigPath, | 384 | keybindingsConfigPath: config.keybindingsConfigPath, |
| 336 | keybindings: keybindingsConfig.keybindings, | 385 | keybindings: keybindingsConfig.keybindings, |
| ... | @@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 435 | ), | 484 | ), |
| 436 | ).pipe( | 485 | ).pipe( |
| 437 | Effect.map((events) => Array.from(events)), | 486 | Effect.map((events) => Array.from(events)), |
| 487 | Effect.flatMap(enrichOrchestrationEvents), | ||
| 438 | Effect.mapError( | 488 | Effect.mapError( |
| 439 | (cause) => | 489 | (cause) => |
| 440 | new OrchestrationReplayEventsError({ | 490 | new OrchestrationReplayEventsError({ |
| ... | @@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 455 | orchestrationEngine.readEvents(fromSequenceExclusive), | 505 | orchestrationEngine.readEvents(fromSequenceExclusive), |
| 456 | ).pipe( | 506 | ).pipe( |
| 457 | Effect.map((events) => Array.from(events)), | 507 | Effect.map((events) => Array.from(events)), |
| 508 | Effect.flatMap(enrichOrchestrationEvents), | ||
| 458 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), | 509 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), |
| 459 | ); | 510 | ); |
| 460 | const replayStream = Stream.fromIterable(replayEvents); | 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 | type SequenceState = { | 516 | type SequenceState = { |
| 463 | readonly nextSequence: number; | 517 | readonly nextSequence: number; |
| 464 | readonly pendingBySequence: Map<number, OrchestrationEvent>; | 518 | readonly pendingBySequence: Map<number, OrchestrationEvent>; |
apps/web/index.html+1| ... | @@ -12,6 +12,7 @@ | ... | @@ -12,6 +12,7 @@ |
| 12 | rel="stylesheet" | 12 | rel="stylesheet" |
| 13 | /> | 13 | /> |
| 14 | <title>T3 Code (Alpha)</title> | 14 | <title>T3 Code (Alpha)</title> |
| 15 | <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script> | ||
| 15 | </head> | 16 | </head> |
| 16 | <body> | 17 | <body> |
| 17 | <div id="root"></div> | 18 | <div id="root"></div> |
apps/web/package.json+1| ... | @@ -23,6 +23,7 @@ | ... | @@ -23,6 +23,7 @@ |
| 23 | "@formkit/auto-animate": "^0.9.0", | 23 | "@formkit/auto-animate": "^0.9.0", |
| 24 | "@lexical/react": "^0.41.0", | 24 | "@lexical/react": "^0.41.0", |
| 25 | "@pierre/diffs": "^1.1.0-beta.16", | 25 | "@pierre/diffs": "^1.1.0-beta.16", |
| 26 | "@t3tools/client-runtime": "workspace:*", | ||
| 26 | "@t3tools/contracts": "workspace:*", | 27 | "@t3tools/contracts": "workspace:*", |
| 27 | "@t3tools/shared": "workspace:*", | 28 | "@t3tools/shared": "workspace:*", |
| 28 | "@tanstack/react-pacer": "^0.19.4", | 29 | "@tanstack/react-pacer": "^0.19.4", |
apps/web/src/components/BranchToolbar.tsx+36-14| ... | @@ -1,11 +1,13 @@ | ... | @@ -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 | import { FolderIcon, GitForkIcon } from "lucide-react"; | 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 | import { newCommandId } from "../lib/utils"; | 8 | import { newCommandId } from "../lib/utils"; |
| 6 | import { readNativeApi } from "../nativeApi"; | ||
| 7 | import { useComposerDraftStore } from "../composerDraftStore"; | ||
| 8 | import { useStore } from "../store"; | 9 | import { useStore } from "../store"; |
| 10 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | ||
| 9 | import { | 11 | import { |
| 10 | EnvMode, | 12 | EnvMode, |
| 11 | resolveDraftEnvModeAfterBranchChange, | 13 | resolveDraftEnvModeAfterBranchChange, |
| ... | @@ -20,7 +22,9 @@ const envModeItems = [ | ... | @@ -20,7 +22,9 @@ const envModeItems = [ |
| 20 | ] as const; | 22 | ] as const; |
| 21 | 23 | ||
| 22 | interface BranchToolbarProps { | 24 | interface BranchToolbarProps { |
| 25 | environmentId: EnvironmentId; | ||
| 23 | threadId: ThreadId; | 26 | threadId: ThreadId; |
| 27 | draftId?: DraftId; | ||
| 24 | onEnvModeChange: (mode: EnvMode) => void; | 28 | onEnvModeChange: (mode: EnvMode) => void; |
| 25 | envLocked: boolean; | 29 | envLocked: boolean; |
| 26 | onCheckoutPullRequestRequest?: (reference: string) => void; | 30 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| ... | @@ -28,21 +32,34 @@ interface BranchToolbarProps { | ... | @@ -28,21 +32,34 @@ interface BranchToolbarProps { |
| 28 | } | 32 | } |
| 29 | 33 | ||
| 30 | export default function BranchToolbar({ | 34 | export default function BranchToolbar({ |
| 35 | environmentId, | ||
| 31 | threadId, | 36 | threadId, |
| 37 | draftId, | ||
| 32 | onEnvModeChange, | 38 | onEnvModeChange, |
| 33 | envLocked, | 39 | envLocked, |
| 34 | onCheckoutPullRequestRequest, | 40 | onCheckoutPullRequestRequest, |
| 35 | onComposerFocusRequest, | 41 | onComposerFocusRequest, |
| 36 | }: BranchToolbarProps) { | 42 | }: BranchToolbarProps) { |
| 37 | const serverThread = useStore((store) => store.threadShellById[threadId]); | 43 | const threadRef = useMemo( |
| 38 | const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); | 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 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | 50 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); |
| 40 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); | 51 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); |
| 41 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 52 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 42 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; | 53 | const activeProjectRef = serverThread |
| 43 | const activeProject = useStore((store) => | 54 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 44 | activeProjectId ? store.projectById[activeProjectId] : undefined, | 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 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); | 63 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); |
| 47 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; | 64 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; |
| 48 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 65 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| ... | @@ -56,8 +73,8 @@ export default function BranchToolbar({ | ... | @@ -56,8 +73,8 @@ export default function BranchToolbar({ |
| 56 | 73 | ||
| 57 | const setThreadBranch = useCallback( | 74 | const setThreadBranch = useCallback( |
| 58 | (branch: string | null, worktreePath: string | null) => { | 75 | (branch: string | null, worktreePath: string | null) => { |
| 59 | if (!activeThreadId) return; | 76 | if (!activeThreadId || !activeProject) return; |
| 60 | const api = readNativeApi(); | 77 | const api = readEnvironmentApi(environmentId); |
| 61 | // If the effective cwd is about to change, stop the running session so the | 78 | // If the effective cwd is about to change, stop the running session so the |
| 62 | // next message creates a new one with the correct cwd. | 79 | // next message creates a new one with the correct cwd. |
| 63 | if (serverSession && worktreePath !== activeWorktreePath && api) { | 80 | if (serverSession && worktreePath !== activeWorktreePath && api) { |
| ... | @@ -88,20 +105,24 @@ export default function BranchToolbar({ | ... | @@ -88,20 +105,24 @@ export default function BranchToolbar({ |
| 88 | currentWorktreePath: activeWorktreePath, | 105 | currentWorktreePath: activeWorktreePath, |
| 89 | effectiveEnvMode, | 106 | effectiveEnvMode, |
| 90 | }); | 107 | }); |
| 91 | setDraftThreadContext(threadId, { | 108 | setDraftThreadContext(draftId ?? threadRef, { |
| 92 | branch, | 109 | branch, |
| 93 | worktreePath, | 110 | worktreePath, |
| 94 | envMode: nextDraftEnvMode, | 111 | envMode: nextDraftEnvMode, |
| 112 | projectRef: scopeProjectRef(environmentId, activeProject.id), | ||
| 95 | }); | 113 | }); |
| 96 | }, | 114 | }, |
| 97 | [ | 115 | [ |
| 98 | activeThreadId, | 116 | activeThreadId, |
| 117 | activeProject, | ||
| 99 | serverSession, | 118 | serverSession, |
| 100 | activeWorktreePath, | 119 | activeWorktreePath, |
| 101 | hasServerThread, | 120 | hasServerThread, |
| 102 | setThreadBranchAction, | 121 | setThreadBranchAction, |
| 103 | setDraftThreadContext, | 122 | setDraftThreadContext, |
| 104 | threadId, | 123 | draftId, |
| 124 | threadRef, | ||
| 125 | environmentId, | ||
| 105 | effectiveEnvMode, | 126 | effectiveEnvMode, |
| 106 | ], | 127 | ], |
| 107 | ); | 128 | ); |
| ... | @@ -156,6 +177,7 @@ export default function BranchToolbar({ | ... | @@ -156,6 +177,7 @@ export default function BranchToolbar({ |
| 156 | )} | 177 | )} |
| 157 | 178 | ||
| 158 | <BranchToolbarBranchSelector | 179 | <BranchToolbarBranchSelector |
| 180 | environmentId={environmentId} | ||
| 159 | activeProjectCwd={activeProject.cwd} | 181 | activeProjectCwd={activeProject.cwd} |
| 160 | activeThreadBranch={activeThreadBranch} | 182 | activeThreadBranch={activeThreadBranch} |
| 161 | activeWorktreePath={activeWorktreePath} | 183 | activeWorktreePath={activeWorktreePath} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+13-10| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, GitBranch } from "@t3tools/contracts"; |
| 2 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; | 2 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useVirtualizer } from "@tanstack/react-virtual"; | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | import { ChevronDownIcon } from "lucide-react"; | 4 | import { ChevronDownIcon } from "lucide-react"; |
| ... | @@ -16,7 +16,7 @@ import { | ... | @@ -16,7 +16,7 @@ import { |
| 16 | 16 | ||
| 17 | import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; | 17 | import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; |
| 18 | import { useGitStatus } from "../lib/gitStatusState"; | 18 | import { useGitStatus } from "../lib/gitStatusState"; |
| 19 | import { readNativeApi } from "../nativeApi"; | 19 | import { readEnvironmentApi } from "../environmentApi"; |
| 20 | import { parsePullRequestReference } from "../pullRequestReference"; | 20 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 21 | import { | 21 | import { |
| 22 | deriveLocalBranchNameFromRemoteRef, | 22 | deriveLocalBranchNameFromRemoteRef, |
| ... | @@ -39,6 +39,7 @@ import { | ... | @@ -39,6 +39,7 @@ import { |
| 39 | import { toastManager } from "./ui/toast"; | 39 | import { toastManager } from "./ui/toast"; |
| 40 | 40 | ||
| 41 | interface BranchToolbarBranchSelectorProps { | 41 | interface BranchToolbarBranchSelectorProps { |
| 42 | environmentId: EnvironmentId; | ||
| 42 | activeProjectCwd: string; | 43 | activeProjectCwd: string; |
| 43 | activeThreadBranch: string | null; | 44 | activeThreadBranch: string | null; |
| 44 | activeWorktreePath: string | null; | 45 | activeWorktreePath: string | null; |
| ... | @@ -70,6 +71,7 @@ function getBranchTriggerLabel(input: { | ... | @@ -70,6 +71,7 @@ function getBranchTriggerLabel(input: { |
| 70 | } | 71 | } |
| 71 | 72 | ||
| 72 | export function BranchToolbarBranchSelector({ | 73 | export function BranchToolbarBranchSelector({ |
| 74 | environmentId, | ||
| 73 | activeProjectCwd, | 75 | activeProjectCwd, |
| 74 | activeThreadBranch, | 76 | activeThreadBranch, |
| 75 | activeWorktreePath, | 77 | activeWorktreePath, |
| ... | @@ -85,16 +87,16 @@ export function BranchToolbarBranchSelector({ | ... | @@ -85,16 +87,16 @@ export function BranchToolbarBranchSelector({ |
| 85 | const [branchQuery, setBranchQuery] = useState(""); | 87 | const [branchQuery, setBranchQuery] = useState(""); |
| 86 | const deferredBranchQuery = useDeferredValue(branchQuery); | 88 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 87 | 89 | ||
| 88 | const branchStatusQuery = useGitStatus(branchCwd); | 90 | const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); |
| 89 | const trimmedBranchQuery = branchQuery.trim(); | 91 | const trimmedBranchQuery = branchQuery.trim(); |
| 90 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); | 92 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); |
| 91 | 93 | ||
| 92 | useEffect(() => { | 94 | useEffect(() => { |
| 93 | if (!branchCwd) return; | 95 | if (!branchCwd) return; |
| 94 | void queryClient.prefetchInfiniteQuery( | 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 | const { | 101 | const { |
| 100 | data: branchesSearchData, | 102 | data: branchesSearchData, |
| ... | @@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({ |
| 104 | isPending: isBranchesSearchPending, | 106 | isPending: isBranchesSearchPending, |
| 105 | } = useInfiniteQuery( | 107 | } = useInfiniteQuery( |
| 106 | gitBranchSearchInfiniteQueryOptions({ | 108 | gitBranchSearchInfiniteQueryOptions({ |
| 109 | environmentId, | ||
| 107 | cwd: branchCwd, | 110 | cwd: branchCwd, |
| 108 | query: deferredTrimmedBranchQuery, | 111 | query: deferredTrimmedBranchQuery, |
| 109 | }), | 112 | }), |
| ... | @@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({ | ... | @@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({ |
| 184 | startBranchActionTransition(async () => { | 187 | startBranchActionTransition(async () => { |
| 185 | await action().catch(() => undefined); | 188 | await action().catch(() => undefined); |
| 186 | await queryClient | 189 | await queryClient |
| 187 | .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) }) | 190 | .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) |
| 188 | .catch(() => undefined); | 191 | .catch(() => undefined); |
| 189 | }); | 192 | }); |
| 190 | }; | 193 | }; |
| 191 | 194 | ||
| 192 | const selectBranch = (branch: GitBranch) => { | 195 | const selectBranch = (branch: GitBranch) => { |
| 193 | const api = readNativeApi(); | 196 | const api = readEnvironmentApi(environmentId); |
| 194 | if (!api || !branchCwd || isBranchActionPending) return; | 197 | if (!api || !branchCwd || isBranchActionPending) return; |
| 195 | 198 | ||
| 196 | // In new-worktree mode, selecting a branch sets the base branch. | 199 | // In new-worktree mode, selecting a branch sets the base branch. |
| ... | @@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({ |
| 248 | 251 | ||
| 249 | const createBranch = (rawName: string) => { | 252 | const createBranch = (rawName: string) => { |
| 250 | const name = rawName.trim(); | 253 | const name = rawName.trim(); |
| 251 | const api = readNativeApi(); | 254 | const api = readEnvironmentApi(environmentId); |
| 252 | if (!api || !branchCwd || !name || isBranchActionPending) return; | 255 | if (!api || !branchCwd || !name || isBranchActionPending) return; |
| 253 | 256 | ||
| 254 | setIsBranchMenuOpen(false); | 257 | setIsBranchMenuOpen(false); |
| ... | @@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({ | ... | @@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({ |
| 302 | return; | 305 | return; |
| 303 | } | 306 | } |
| 304 | void queryClient.invalidateQueries({ | 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 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); | 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,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering"; |
| 22 | import { LRUCache } from "../lib/lruCache"; | 22 | import { LRUCache } from "../lib/lruCache"; |
| 23 | import { useTheme } from "../hooks/useTheme"; | 23 | import { useTheme } from "../hooks/useTheme"; |
| 24 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; | 24 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; |
| 25 | import { readNativeApi } from "../nativeApi"; | 25 | import { readLocalApi } from "../localApi"; |
| 26 | 26 | ||
| 27 | class CodeHighlightErrorBoundary extends React.Component< | 27 | class CodeHighlightErrorBoundary extends React.Component< |
| 28 | { fallback: ReactNode; children: ReactNode }, | 28 | { fallback: ReactNode; children: ReactNode }, |
| ... | @@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | ... | @@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 253 | onClick={(event) => { | 253 | onClick={(event) => { |
| 254 | event.preventDefault(); | 254 | event.preventDefault(); |
| 255 | event.stopPropagation(); | 255 | event.stopPropagation(); |
| 256 | const api = readNativeApi(); | 256 | const api = readLocalApi(); |
| 257 | if (api) { | 257 | if (api) { |
| 258 | void openInPreferredEditor(api, targetPath); | 258 | void openInPreferredEditor(api, targetPath); |
| 259 | } else { | 259 | } else { |
apps/web/src/components/ChatView.browser.tsx+330-229| ... | @@ -4,6 +4,7 @@ import "../index.css"; | ... | @@ -4,6 +4,7 @@ import "../index.css"; |
| 4 | import { | 4 | import { |
| 5 | EventId, | 5 | EventId, |
| 6 | ORCHESTRATION_WS_METHODS, | 6 | ORCHESTRATION_WS_METHODS, |
| 7 | EnvironmentId, | ||
| 7 | type MessageId, | 8 | type MessageId, |
| 8 | type OrchestrationEvent, | 9 | type OrchestrationEvent, |
| 9 | type OrchestrationReadModel, | 10 | type OrchestrationReadModel, |
| ... | @@ -12,11 +13,16 @@ import { | ... | @@ -12,11 +13,16 @@ import { |
| 12 | type ServerLifecycleWelcomePayload, | 13 | type ServerLifecycleWelcomePayload, |
| 13 | type ThreadId, | 14 | type ThreadId, |
| 14 | type TurnId, | 15 | type TurnId, |
| 15 | type UserInputQuestion, | ||
| 16 | WS_METHODS, | 16 | WS_METHODS, |
| 17 | OrchestrationSessionStatus, | 17 | OrchestrationSessionStatus, |
| 18 | DEFAULT_SERVER_SETTINGS, | 18 | DEFAULT_SERVER_SETTINGS, |
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import { | ||
| 21 | scopedProjectKey, | ||
| 22 | scopedThreadKey, | ||
| 23 | scopeProjectRef, | ||
| 24 | scopeThreadRef, | ||
| 25 | } from "@t3tools/client-runtime"; | ||
| 20 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | 26 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 21 | import { HttpResponse, http, ws } from "msw"; | 27 | import { HttpResponse, http, ws } from "msw"; |
| 22 | import { setupWorker } from "msw/browser"; | 28 | import { setupWorker } from "msw/browser"; |
| ... | @@ -24,19 +30,20 @@ import { page } from "vitest/browser"; | ... | @@ -24,19 +30,20 @@ import { page } from "vitest/browser"; |
| 24 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; | 30 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| 25 | import { render } from "vitest-browser-react"; | 31 | import { render } from "vitest-browser-react"; |
| 26 | 32 | ||
| 27 | import { useComposerDraftStore } from "../composerDraftStore"; | 33 | import { useComposerDraftStore, DraftId } from "../composerDraftStore"; |
| 28 | import { | 34 | import { |
| 29 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | 35 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, |
| 30 | type TerminalContextDraft, | 36 | type TerminalContextDraft, |
| 31 | removeInlineTerminalContextPlaceholder, | 37 | removeInlineTerminalContextPlaceholder, |
| 32 | } from "../lib/terminalContext"; | 38 | } from "../lib/terminalContext"; |
| 33 | import { isMacPlatform } from "../lib/utils"; | 39 | import { isMacPlatform } from "../lib/utils"; |
| 34 | import { __resetNativeApiForTests } from "../nativeApi"; | 40 | import { __resetLocalApiForTests } from "../localApi"; |
| 35 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | 41 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; |
| 36 | import { getServerConfig } from "../rpc/serverState"; | 42 | import { getServerConfig } from "../rpc/serverState"; |
| 37 | import { getRouter } from "../router"; | 43 | import { getRouter } from "../router"; |
| 38 | import { useStore } from "../store"; | 44 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; |
| 39 | import { useTerminalStateStore } from "../terminalStateStore"; | 45 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 46 | import { useUiStateStore } from "../uiStateStore"; | ||
| 40 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; | 47 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; |
| 41 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 48 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 42 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | 49 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| ... | @@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({ | ... | @@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({ |
| 49 | })); | 56 | })); |
| 50 | 57 | ||
| 51 | const THREAD_ID = "thread-browser-test" as ThreadId; | 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 | const PROJECT_ID = "project-1" as ProjectId; | 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 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | 66 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 55 | const BASE_TIME_MS = Date.parse(NOW_ISO); | 67 | const BASE_TIME_MS = Date.parse(NOW_ISO); |
| 56 | const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>"; | 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,6 +141,13 @@ function isoAt(offsetSeconds: number): string { |
| 129 | 141 | ||
| 130 | function createBaseServerConfig(): ServerConfig { | 142 | function createBaseServerConfig(): ServerConfig { |
| 131 | return { | 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 | cwd: "/repo/project", | 151 | cwd: "/repo/project", |
| 133 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | 152 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 134 | keybindings: [], | 153 | keybindings: [], |
| ... | @@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { | ... | @@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { |
| 314 | snapshot, | 333 | snapshot, |
| 315 | serverConfig: createBaseServerConfig(), | 334 | serverConfig: createBaseServerConfig(), |
| 316 | welcome: { | 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 | cwd: "/repo/project", | 343 | cwd: "/repo/project", |
| 318 | projectName: "Project", | 344 | projectName: "Project", |
| 319 | bootstrapProjectId: PROJECT_ID, | 345 | bootstrapProjectId: PROJECT_ID, |
| ... | @@ -396,6 +422,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest | ... | @@ -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 | function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { | 452 | function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { |
| 400 | rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); | 453 | rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); |
| 401 | } | 454 | } |
| ... | @@ -419,25 +472,72 @@ async function waitForWsClient(): Promise<void> { | ... | @@ -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 | async function waitForAppBootstrap(): Promise<void> { | 509 | async function waitForAppBootstrap(): Promise<void> { |
| 423 | await vi.waitFor( | 510 | await vi.waitFor( |
| 424 | () => { | 511 | () => { |
| 425 | expect(getServerConfig()).not.toBeNull(); | 512 | expect(getServerConfig()).not.toBeNull(); |
| 426 | expect(useStore.getState().bootstrapComplete).toBe(true); | 513 | expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); |
| 427 | }, | 514 | }, |
| 428 | { timeout: 8_000, interval: 16 }, | 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 | await waitForWsClient(); | 520 | await waitForWsClient(); |
| 434 | fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); | 521 | fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); |
| 435 | sendOrchestrationDomainEvent( | 522 | sendOrchestrationDomainEvent( |
| 436 | createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), | 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 | await vi.waitFor( | 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 | { timeout: 8_000, interval: 16 }, | 542 | { timeout: 8_000, interval: 16 }, |
| 443 | ); | 543 | ); |
| ... | @@ -468,9 +568,12 @@ function withProjectScripts( | ... | @@ -468,9 +568,12 @@ function withProjectScripts( |
| 468 | 568 | ||
| 469 | function setDraftThreadWithoutWorktree(): void { | 569 | function setDraftThreadWithoutWorktree(): void { |
| 470 | useComposerDraftStore.setState({ | 570 | useComposerDraftStore.setState({ |
| 471 | draftThreadsByThreadId: { | 571 | draftThreadsByThreadKey: { |
| 472 | [THREAD_ID]: { | 572 | [THREAD_KEY]: { |
| 573 | threadId: THREAD_ID, | ||
| 574 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 473 | projectId: PROJECT_ID, | 575 | projectId: PROJECT_ID, |
| 576 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 474 | createdAt: NOW_ISO, | 577 | createdAt: NOW_ISO, |
| 475 | runtimeMode: "full-access", | 578 | runtimeMode: "full-access", |
| 476 | interactionMode: "default", | 579 | interactionMode: "default", |
| ... | @@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void { | ... | @@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void { |
| 479 | envMode: "local", | 582 | envMode: "local", |
| 480 | }, | 583 | }, |
| 481 | }, | 584 | }, |
| 482 | projectDraftThreadIdByProjectId: { | 585 | logicalProjectDraftThreadKeyByLogicalProjectKey: { |
| 483 | [PROJECT_ID]: THREAD_ID, | 586 | [PROJECT_DRAFT_KEY]: THREAD_KEY, |
| 484 | }, | 587 | }, |
| 485 | }); | 588 | }); |
| 486 | } | 589 | } |
| ... | @@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { | ... | @@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { |
| 542 | }; | 645 | }; |
| 543 | } | 646 | } |
| 544 | 647 | ||
| 545 | function createSnapshotWithPendingUserInput(options?: { | 648 | function createSnapshotWithPendingUserInput(): OrchestrationReadModel { |
| 546 | questions?: ReadonlyArray<UserInputQuestion>; | ||
| 547 | }): OrchestrationReadModel { | ||
| 548 | const snapshot = createSnapshotForTargetUser({ | 649 | const snapshot = createSnapshotForTargetUser({ |
| 549 | targetMessageId: "msg-user-pending-input-target" as MessageId, | 650 | targetMessageId: "msg-user-pending-input-target" as MessageId, |
| 550 | targetText: "question thread", | 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 | return { | 654 | return { |
| 591 | ...snapshot, | 655 | ...snapshot, |
| 592 | threads: snapshot.threads.map((thread) => | 656 | threads: snapshot.threads.map((thread) => |
| ... | @@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: { | ... | @@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: { |
| 601 | summary: "User input requested", | 665 | summary: "User input requested", |
| 602 | payload: { | 666 | payload: { |
| 603 | requestId: "req-browser-user-input", | 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 | turnId: null, | 701 | turnId: null, |
| 607 | sequence: 1, | 702 | sequence: 1, |
| ... | @@ -1057,7 +1152,7 @@ async function mountChatView(options: { | ... | @@ -1057,7 +1152,7 @@ async function mountChatView(options: { |
| 1057 | 1152 | ||
| 1058 | const router = getRouter( | 1153 | const router = getRouter( |
| 1059 | createMemoryHistory({ | 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,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1162 | return []; | 1257 | return []; |
| 1163 | }, | 1258 | }, |
| 1164 | }); | 1259 | }); |
| 1165 | await __resetNativeApiForTests(); | 1260 | await __resetLocalApiForTests(); |
| 1166 | await setViewport(DEFAULT_VIEWPORT); | 1261 | await setViewport(DEFAULT_VIEWPORT); |
| 1167 | localStorage.clear(); | 1262 | localStorage.clear(); |
| 1168 | document.body.innerHTML = ""; | 1263 | document.body.innerHTML = ""; |
| 1169 | wsRequests.length = 0; | 1264 | wsRequests.length = 0; |
| 1170 | customWsRpcResolver = null; | 1265 | customWsRpcResolver = null; |
| 1171 | useComposerDraftStore.setState({ | 1266 | useComposerDraftStore.setState({ |
| 1172 | draftsByThreadId: {}, | 1267 | draftsByThreadKey: {}, |
| 1173 | draftThreadsByThreadId: {}, | 1268 | draftThreadsByThreadKey: {}, |
| 1174 | projectDraftThreadIdByProjectId: {}, | 1269 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 1175 | stickyModelSelectionByProvider: {}, | 1270 | stickyModelSelectionByProvider: {}, |
| 1176 | stickyActiveProvider: null, | 1271 | stickyActiveProvider: null, |
| 1177 | }); | 1272 | }); |
| 1178 | useStore.setState({ | 1273 | useStore.setState({ |
| 1179 | projectIds: [], | 1274 | activeEnvironmentId: null, |
| 1180 | projectById: {}, | 1275 | environmentStateById: {}, |
| 1181 | threadIds: [], | 1276 | }); |
| 1182 | threadIdsByProjectId: {}, | 1277 | useUiStateStore.setState({ |
| 1183 | threadShellById: {}, | 1278 | projectExpandedById: {}, |
| 1184 | threadSessionById: {}, | 1279 | projectOrder: [], |
| 1185 | threadTurnStateById: {}, | 1280 | threadLastVisitedAtById: {}, |
| 1186 | messageIdsByThreadId: {}, | ||
| 1187 | messageByThreadId: {}, | ||
| 1188 | activityIdsByThreadId: {}, | ||
| 1189 | activityByThreadId: {}, | ||
| 1190 | proposedPlanIdsByThreadId: {}, | ||
| 1191 | proposedPlanByThreadId: {}, | ||
| 1192 | turnDiffIdsByThreadId: {}, | ||
| 1193 | turnDiffSummaryByThreadId: {}, | ||
| 1194 | sidebarThreadSummaryById: {}, | ||
| 1195 | bootstrapComplete: false, | ||
| 1196 | }); | 1281 | }); |
| 1197 | useTerminalStateStore.persist.clearStorage(); | 1282 | useTerminalStateStore.persist.clearStorage(); |
| 1198 | useTerminalStateStore.setState({ | 1283 | useTerminalStateStore.setState({ |
| 1199 | terminalStateByThreadId: {}, | 1284 | terminalStateByThreadKey: {}, |
| 1200 | terminalLaunchContextByThreadId: {}, | 1285 | terminalLaunchContextByThreadKey: {}, |
| 1201 | terminalEventEntriesByKey: {}, | 1286 | terminalEventEntriesByKey: {}, |
| 1202 | nextTerminalEventId: 1, | 1287 | nextTerminalEventId: 1, |
| 1203 | }); | 1288 | }); |
| ... | @@ -1241,6 +1326,35 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -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 | it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { | 1358 | it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { |
| 1245 | const userText = "x".repeat(3_200); | 1359 | const userText = "x".repeat(3_200); |
| 1246 | const targetMessageId = "msg-user-target-resize" as MessageId; | 1360 | const targetMessageId = "msg-user-target-resize" as MessageId; |
| ... | @@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1438 | } | 1552 | } |
| 1439 | 1553 | ||
| 1440 | useTerminalStateStore.setState({ | 1554 | useTerminalStateStore.setState({ |
| 1441 | terminalStateByThreadId: { | 1555 | terminalStateByThreadKey: { |
| 1442 | [THREAD_ID]: { | 1556 | [THREAD_KEY]: { |
| 1443 | terminalOpen: true, | 1557 | terminalOpen: true, |
| 1444 | terminalHeight: 280, | 1558 | terminalHeight: 280, |
| 1445 | terminalIds: ["default"], | 1559 | terminalIds: ["default"], |
| ... | @@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1449 | activeTerminalGroupId: "group-default", | 1563 | activeTerminalGroupId: "group-default", |
| 1450 | }, | 1564 | }, |
| 1451 | }, | 1565 | }, |
| 1452 | terminalLaunchContextByThreadId: { | 1566 | terminalLaunchContextByThreadKey: { |
| 1453 | [THREAD_ID]: { | 1567 | [THREAD_KEY]: { |
| 1454 | cwd: "/repo/project", | 1568 | cwd: "/repo/project", |
| 1455 | worktreePath: null, | 1569 | worktreePath: null, |
| 1456 | }, | 1570 | }, |
| ... | @@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1696 | 1810 | ||
| 1697 | it("runs project scripts from local draft threads at the project cwd", async () => { | 1811 | it("runs project scripts from local draft threads at the project cwd", async () => { |
| 1698 | useComposerDraftStore.setState({ | 1812 | useComposerDraftStore.setState({ |
| 1699 | draftThreadsByThreadId: { | 1813 | draftThreadsByThreadKey: { |
| 1700 | [THREAD_ID]: { | 1814 | [THREAD_KEY]: { |
| 1815 | threadId: THREAD_ID, | ||
| 1816 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 1701 | projectId: PROJECT_ID, | 1817 | projectId: PROJECT_ID, |
| 1818 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 1702 | createdAt: NOW_ISO, | 1819 | createdAt: NOW_ISO, |
| 1703 | runtimeMode: "full-access", | 1820 | runtimeMode: "full-access", |
| 1704 | interactionMode: "default", | 1821 | interactionMode: "default", |
| ... | @@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1707 | envMode: "local", | 1824 | envMode: "local", |
| 1708 | }, | 1825 | }, |
| 1709 | }, | 1826 | }, |
| 1710 | projectDraftThreadIdByProjectId: { | 1827 | logicalProjectDraftThreadKeyByLogicalProjectKey: { |
| 1711 | [PROJECT_ID]: THREAD_ID, | 1828 | [PROJECT_DRAFT_KEY]: THREAD_KEY, |
| 1712 | }, | 1829 | }, |
| 1713 | }); | 1830 | }); |
| 1714 | 1831 | ||
| ... | @@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1772 | 1889 | ||
| 1773 | it("runs project scripts from worktree draft threads at the worktree cwd", async () => { | 1890 | it("runs project scripts from worktree draft threads at the worktree cwd", async () => { |
| 1774 | useComposerDraftStore.setState({ | 1891 | useComposerDraftStore.setState({ |
| 1775 | draftThreadsByThreadId: { | 1892 | draftThreadsByThreadKey: { |
| 1776 | [THREAD_ID]: { | 1893 | [THREAD_KEY]: { |
| 1894 | threadId: THREAD_ID, | ||
| 1895 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 1777 | projectId: PROJECT_ID, | 1896 | projectId: PROJECT_ID, |
| 1897 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 1778 | createdAt: NOW_ISO, | 1898 | createdAt: NOW_ISO, |
| 1779 | runtimeMode: "full-access", | 1899 | runtimeMode: "full-access", |
| 1780 | interactionMode: "default", | 1900 | interactionMode: "default", |
| ... | @@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1783 | envMode: "worktree", | 1903 | envMode: "worktree", |
| 1784 | }, | 1904 | }, |
| 1785 | }, | 1905 | }, |
| 1786 | projectDraftThreadIdByProjectId: { | 1906 | logicalProjectDraftThreadKeyByLogicalProjectKey: { |
| 1787 | [PROJECT_ID]: THREAD_ID, | 1907 | [PROJECT_DRAFT_KEY]: THREAD_KEY, |
| 1788 | }, | 1908 | }, |
| 1789 | }); | 1909 | }); |
| 1790 | 1910 | ||
| ... | @@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1835 | 1955 | ||
| 1836 | it("lets the server own setup after preparing a pull request worktree thread", async () => { | 1956 | it("lets the server own setup after preparing a pull request worktree thread", async () => { |
| 1837 | useComposerDraftStore.setState({ | 1957 | useComposerDraftStore.setState({ |
| 1838 | draftThreadsByThreadId: { | 1958 | draftThreadsByThreadKey: { |
| 1839 | [THREAD_ID]: { | 1959 | [THREAD_KEY]: { |
| 1960 | threadId: THREAD_ID, | ||
| 1961 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 1840 | projectId: PROJECT_ID, | 1962 | projectId: PROJECT_ID, |
| 1963 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 1841 | createdAt: NOW_ISO, | 1964 | createdAt: NOW_ISO, |
| 1842 | runtimeMode: "full-access", | 1965 | runtimeMode: "full-access", |
| 1843 | interactionMode: "default", | 1966 | interactionMode: "default", |
| ... | @@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1846 | envMode: "local", | 1969 | envMode: "local", |
| 1847 | }, | 1970 | }, |
| 1848 | }, | 1971 | }, |
| 1849 | projectDraftThreadIdByProjectId: { | 1972 | logicalProjectDraftThreadKeyByLogicalProjectKey: { |
| 1850 | [PROJECT_ID]: THREAD_ID, | 1973 | [PROJECT_DRAFT_KEY]: THREAD_KEY, |
| 1851 | }, | 1974 | }, |
| 1852 | }); | 1975 | }); |
| 1853 | 1976 | ||
| ... | @@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1957 | 2080 | ||
| 1958 | it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { | 2081 | it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { |
| 1959 | useTerminalStateStore.setState({ | 2082 | useTerminalStateStore.setState({ |
| 1960 | terminalStateByThreadId: {}, | 2083 | terminalStateByThreadKey: {}, |
| 1961 | }); | 2084 | }); |
| 1962 | useComposerDraftStore.setState({ | 2085 | useComposerDraftStore.setState({ |
| 1963 | draftThreadsByThreadId: { | 2086 | draftThreadsByThreadKey: { |
| 1964 | [THREAD_ID]: { | 2087 | [THREAD_KEY]: { |
| 2088 | threadId: THREAD_ID, | ||
| 2089 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 1965 | projectId: PROJECT_ID, | 2090 | projectId: PROJECT_ID, |
| 2091 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 1966 | createdAt: NOW_ISO, | 2092 | createdAt: NOW_ISO, |
| 1967 | runtimeMode: "full-access", | 2093 | runtimeMode: "full-access", |
| 1968 | interactionMode: "default", | 2094 | interactionMode: "default", |
| ... | @@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1971 | envMode: "worktree", | 2097 | envMode: "worktree", |
| 1972 | }, | 2098 | }, |
| 1973 | }, | 2099 | }, |
| 1974 | projectDraftThreadIdByProjectId: { | 2100 | logicalProjectDraftThreadKeyByLogicalProjectKey: { |
| 1975 | [PROJECT_ID]: THREAD_ID, | 2101 | [PROJECT_DRAFT_KEY]: THREAD_KEY, |
| 1976 | }, | 2102 | }, |
| 1977 | }); | 2103 | }); |
| 1978 | 2104 | ||
| ... | @@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1998 | }); | 2124 | }); |
| 1999 | 2125 | ||
| 2000 | try { | 2126 | try { |
| 2001 | useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); | 2127 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); |
| 2002 | await waitForLayout(); | 2128 | await waitForLayout(); |
| 2003 | 2129 | ||
| 2004 | const sendButton = await waitForSendButton(); | 2130 | const sendButton = await waitForSendButton(); |
| ... | @@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2057 | 2183 | ||
| 2058 | it("shows the send state once bootstrap dispatch is in flight", async () => { | 2184 | it("shows the send state once bootstrap dispatch is in flight", async () => { |
| 2059 | useTerminalStateStore.setState({ | 2185 | useTerminalStateStore.setState({ |
| 2060 | terminalStateByThreadId: {}, | 2186 | terminalStateByThreadKey: {}, |
| 2061 | }); | 2187 | }); |
| 2062 | useComposerDraftStore.setState({ | 2188 | useComposerDraftStore.setState({ |
| 2063 | draftThreadsByThreadId: { | 2189 | draftThreadsByThreadKey: { |
| 2064 | [THREAD_ID]: { | 2190 | [THREAD_KEY]: { |
| 2191 | threadId: THREAD_ID, | ||
| 2192 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 2065 | projectId: PROJECT_ID, | 2193 | projectId: PROJECT_ID, |
| 2194 | logicalProjectKey: PROJECT_DRAFT_KEY, | ||
| 2066 | createdAt: NOW_ISO, | 2195 | createdAt: NOW_ISO, |
| 2067 | runtimeMode: "full-access", | 2196 | runtimeMode: "full-access", |
| 2068 | interactionMode: "default", | 2197 | interactionMode: "default", |
| ... | @@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2071 | envMode: "worktree", | 2200 | envMode: "worktree", |
| 2072 | }, | 2201 | }, |
| 2073 | }, | 2202 | }, |
| 2074 | projectDraftThreadIdByProjectId: { | 2203 | logicalProjectDraftThreadKeyByLogicalProjectKey: { |
| 2075 | [PROJECT_ID]: THREAD_ID, | 2204 | [PROJECT_DRAFT_KEY]: THREAD_KEY, |
| 2076 | }, | 2205 | }, |
| 2077 | }); | 2206 | }); |
| 2078 | 2207 | ||
| ... | @@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2101 | }); | 2230 | }); |
| 2102 | 2231 | ||
| 2103 | try { | 2232 | try { |
| 2104 | useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); | 2233 | useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); |
| 2105 | await waitForLayout(); | 2234 | await waitForLayout(); |
| 2106 | 2235 | ||
| 2107 | const sendButton = await waitForSendButton(); | 2236 | const sendButton = await waitForSendButton(); |
| ... | @@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2193 | const removedLabel = "Terminal 1 lines 1-2"; | 2322 | const removedLabel = "Terminal 1 lines 1-2"; |
| 2194 | const addedLabel = "Terminal 2 lines 9-10"; | 2323 | const addedLabel = "Terminal 2 lines 9-10"; |
| 2195 | useComposerDraftStore.getState().addTerminalContext( | 2324 | useComposerDraftStore.getState().addTerminalContext( |
| 2196 | THREAD_ID, | 2325 | THREAD_REF, |
| 2197 | createTerminalContext({ | 2326 | createTerminalContext({ |
| 2198 | id: "ctx-removed", | 2327 | id: "ctx-removed", |
| 2199 | terminalLabel: "Terminal 1", | 2328 | terminalLabel: "Terminal 1", |
| ... | @@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2220 | ); | 2349 | ); |
| 2221 | 2350 | ||
| 2222 | const store = useComposerDraftStore.getState(); | 2351 | const store = useComposerDraftStore.getState(); |
| 2223 | const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; | 2352 | const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; |
| 2224 | const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); | 2353 | const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); |
| 2225 | store.setPrompt(THREAD_ID, nextPrompt.prompt); | 2354 | store.setPrompt(THREAD_REF, nextPrompt.prompt); |
| 2226 | store.removeTerminalContext(THREAD_ID, "ctx-removed"); | 2355 | store.removeTerminalContext(THREAD_REF, "ctx-removed"); |
| 2227 | 2356 | ||
| 2228 | await vi.waitFor( | 2357 | await vi.waitFor( |
| 2229 | () => { | 2358 | () => { |
| 2230 | expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); | 2359 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); |
| 2231 | expect(document.body.textContent).not.toContain(removedLabel); | 2360 | expect(document.body.textContent).not.toContain(removedLabel); |
| 2232 | }, | 2361 | }, |
| 2233 | { timeout: 8_000, interval: 16 }, | 2362 | { timeout: 8_000, interval: 16 }, |
| 2234 | ); | 2363 | ); |
| 2235 | 2364 | ||
| 2236 | useComposerDraftStore.getState().addTerminalContext( | 2365 | useComposerDraftStore.getState().addTerminalContext( |
| 2237 | THREAD_ID, | 2366 | THREAD_REF, |
| 2238 | createTerminalContext({ | 2367 | createTerminalContext({ |
| 2239 | id: "ctx-added", | 2368 | id: "ctx-added", |
| 2240 | terminalLabel: "Terminal 2", | 2369 | terminalLabel: "Terminal 2", |
| ... | @@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2246 | 2375 | ||
| 2247 | await vi.waitFor( | 2376 | await vi.waitFor( |
| 2248 | () => { | 2377 | () => { |
| 2249 | const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; | 2378 | const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; |
| 2250 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); | 2379 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); |
| 2251 | expect(document.body.textContent).toContain(addedLabel); | 2380 | expect(document.body.textContent).toContain(addedLabel); |
| 2252 | expect(document.body.textContent).not.toContain(removedLabel); | 2381 | expect(document.body.textContent).not.toContain(removedLabel); |
| ... | @@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2261 | it("disables send when the composer only contains an expired terminal pill", async () => { | 2390 | it("disables send when the composer only contains an expired terminal pill", async () => { |
| 2262 | const expiredLabel = "Terminal 1 line 4"; | 2391 | const expiredLabel = "Terminal 1 line 4"; |
| 2263 | useComposerDraftStore.getState().addTerminalContext( | 2392 | useComposerDraftStore.getState().addTerminalContext( |
| 2264 | THREAD_ID, | 2393 | THREAD_REF, |
| 2265 | createTerminalContext({ | 2394 | createTerminalContext({ |
| 2266 | id: "ctx-expired-only", | 2395 | id: "ctx-expired-only", |
| 2267 | terminalLabel: "Terminal 1", | 2396 | terminalLabel: "Terminal 1", |
| ... | @@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2297 | it("warns when sending text while omitting expired terminal pills", async () => { | 2426 | it("warns when sending text while omitting expired terminal pills", async () => { |
| 2298 | const expiredLabel = "Terminal 1 line 4"; | 2427 | const expiredLabel = "Terminal 1 line 4"; |
| 2299 | useComposerDraftStore.getState().addTerminalContext( | 2428 | useComposerDraftStore.getState().addTerminalContext( |
| 2300 | THREAD_ID, | 2429 | THREAD_REF, |
| 2301 | createTerminalContext({ | 2430 | createTerminalContext({ |
| 2302 | id: "ctx-expired-send-warning", | 2431 | id: "ctx-expired-send-warning", |
| 2303 | terminalLabel: "Terminal 1", | 2432 | terminalLabel: "Terminal 1", |
| ... | @@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2308 | ); | 2437 | ); |
| 2309 | useComposerDraftStore | 2438 | useComposerDraftStore |
| 2310 | .getState() | 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 | const mounted = await mountChatView({ | 2442 | const mounted = await mountChatView({ |
| 2314 | viewport: DEFAULT_VIEWPORT, | 2443 | viewport: DEFAULT_VIEWPORT, |
| ... | @@ -2448,7 +2577,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -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 | const mounted = await mountChatView({ | 2581 | const mounted = await mountChatView({ |
| 2453 | viewport: DEFAULT_VIEWPORT, | 2582 | viewport: DEFAULT_VIEWPORT, |
| 2454 | snapshot: createSnapshotForTargetUser({ | 2583 | snapshot: createSnapshotForTargetUser({ |
| ... | @@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2470 | (path) => UUID_ROUTE_RE.test(path), | 2599 | (path) => UUID_ROUTE_RE.test(path), |
| 2471 | "Route should have changed to a new draft thread UUID.", | 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 | // The composer editor should be present for the new draft thread. | 2605 | // The composer editor should be present for the new draft thread. |
| 2476 | await waitForComposerEditor(); | 2606 | await waitForComposerEditor(); |
| 2477 | 2607 | ||
| 2478 | // Simulate the steady-state promotion path: the server emits | 2608 | // `thread.created` should only mark the draft as promoting; it should |
| 2479 | // `thread.created`, the client materializes the thread incrementally, | 2609 | // not navigate away until the server thread has actual runtime state. |
| 2480 | // and the draft is cleared by live batch effects. | 2610 | await materializePromotedDraftThreadViaDomainEvent(newThreadId); |
| 2481 | await promoteDraftThreadViaDomainEvent(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 | await waitForURL( | 2626 | await waitForURL( |
| 2485 | mounted.router, | 2627 | mounted.router, |
| 2486 | (path) => path === newThreadPath, | 2628 | (path) => path === serverThreadPath(newThreadId), |
| 2487 | "New thread should remain selected after server thread promotion clears the draft.", | 2629 | "Promoted drafts should canonicalize to the server thread route.", |
| 2488 | ); | 2630 | ); |
| 2489 | 2631 | ||
| 2490 | // The empty thread view and composer should still be visible. | 2632 | // The empty thread view and composer should still be visible. |
| ... | @@ -2497,6 +2639,48 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -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 | it("snapshots sticky codex settings into a new draft thread", async () => { | 2684 | it("snapshots sticky codex settings into a new draft thread", async () => { |
| 2501 | useComposerDraftStore.setState({ | 2685 | useComposerDraftStore.setState({ |
| 2502 | stickyModelSelectionByProvider: { | 2686 | stickyModelSelectionByProvider: { |
| ... | @@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2531 | (path) => UUID_ROUTE_RE.test(path), | 2715 | (path) => UUID_ROUTE_RE.test(path), |
| 2532 | "Route should have changed to a new draft thread UUID.", | 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 | modelSelectionByProvider: { | 2721 | modelSelectionByProvider: { |
| 2538 | codex: { | 2722 | codex: { |
| 2539 | provider: "codex", | 2723 | provider: "codex", |
| ... | @@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2584 | (path) => UUID_ROUTE_RE.test(path), | 2768 | (path) => UUID_ROUTE_RE.test(path), |
| 2585 | "Route should have changed to a new sticky claude draft thread UUID.", | 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 | modelSelectionByProvider: { | 2774 | modelSelectionByProvider: { |
| 2591 | claudeAgent: { | 2775 | claudeAgent: { |
| 2592 | provider: "claudeAgent", | 2776 | provider: "claudeAgent", |
| ... | @@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2624 | (path) => UUID_ROUTE_RE.test(path), | 2808 | (path) => UUID_ROUTE_RE.test(path), |
| 2625 | "Route should have changed to a new draft thread UUID.", | 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 | } finally { | 2814 | } finally { |
| 2631 | await mounted.cleanup(); | 2815 | await mounted.cleanup(); |
| 2632 | } | 2816 | } |
| ... | @@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2666 | (path) => UUID_ROUTE_RE.test(path), | 2850 | (path) => UUID_ROUTE_RE.test(path), |
| 2667 | "Route should have changed to a sticky draft thread UUID.", | 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 | modelSelectionByProvider: { | 2856 | modelSelectionByProvider: { |
| 2673 | codex: { | 2857 | codex: { |
| 2674 | provider: "codex", | 2858 | provider: "codex", |
| ... | @@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2681 | activeProvider: "codex", | 2865 | activeProvider: "codex", |
| 2682 | }); | 2866 | }); |
| 2683 | 2867 | ||
| 2684 | useComposerDraftStore.getState().setModelSelection(threadId, { | 2868 | useComposerDraftStore.getState().setModelSelection(draftId, { |
| 2685 | provider: "codex", | 2869 | provider: "codex", |
| 2686 | model: "gpt-5.4", | 2870 | model: "gpt-5.4", |
| 2687 | options: { | 2871 | options: { |
| ... | @@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2697 | (path) => path === threadPath, | 2881 | (path) => path === threadPath, |
| 2698 | "New-thread should reuse the existing project draft thread.", | 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 | modelSelectionByProvider: { | 2885 | modelSelectionByProvider: { |
| 2702 | codex: { | 2886 | codex: { |
| 2703 | provider: "codex", | 2887 | provider: "codex", |
| ... | @@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2804 | (path) => UUID_ROUTE_RE.test(path), | 2988 | (path) => UUID_ROUTE_RE.test(path), |
| 2805 | "Route should have changed to a promoted draft thread UUID.", | 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 | await promoteDraftThreadViaDomainEvent(promotedThreadId); | 2994 | await promoteDraftThreadViaDomainEvent(promotedThreadId); |
| 2810 | 2995 | ||
| ... | @@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -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 | const mounted = await mountChatView({ | 3114 | const mounted = await mountChatView({ |
| 2930 | viewport: WIDE_FOOTER_VIEWPORT, | 3115 | viewport: DEFAULT_VIEWPORT, |
| 2931 | snapshot: createSnapshotWithPendingUserInput(), | 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 | resolveRpc: (body) => { | 3117 | resolveRpc: (body) => { |
| 2993 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | 3118 | if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { |
| 2994 | return { | 3119 | return { |
| ... | @@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3000 | }); | 3125 | }); |
| 3001 | 3126 | ||
| 3002 | try { | 3127 | try { |
| 3003 | const serverOption = await waitForButtonContainingText("Server"); | 3128 | const firstOption = await waitForButtonContainingText("Tight"); |
| 3004 | serverOption.click(); | 3129 | firstOption.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(); | ||
| 3030 | 3130 | ||
| 3031 | const submitButton = await waitForButtonByText("Submit answers"); | 3131 | const finalOption = await waitForButtonContainingText("Conservative"); |
| 3032 | expect(submitButton.disabled).toBe(false); | 3132 | finalOption.click(); |
| 3033 | submitButton.click(); | ||
| 3034 | 3133 | ||
| 3035 | await vi.waitFor( | 3134 | await vi.waitFor( |
| 3036 | () => { | 3135 | () => { |
| ... | @@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3042 | | { | 3141 | | { |
| 3043 | _tag: string; | 3142 | _tag: string; |
| 3044 | type?: string; | 3143 | type?: string; |
| 3144 | requestId?: string; | ||
| 3045 | answers?: Record<string, unknown>; | 3145 | answers?: Record<string, unknown>; |
| 3046 | } | 3146 | } |
| 3047 | | undefined; | 3147 | | undefined; |
| ... | @@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3049 | expect(dispatchRequest).toMatchObject({ | 3149 | expect(dispatchRequest).toMatchObject({ |
| 3050 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, | 3150 | _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 3051 | type: "thread.user-input.respond", | 3151 | type: "thread.user-input.respond", |
| 3152 | requestId: "req-browser-user-input", | ||
| 3052 | answers: { | 3153 | answers: { |
| 3053 | scope: ["Server", "Web"], | 3154 | scope: "Tight", |
| 3054 | risk: "Balanced", | 3155 | risk: "Conservative", |
| 3055 | }, | 3156 | }, |
| 3056 | }); | 3157 | }); |
| 3057 | }, | 3158 | }, |
apps/web/src/components/ChatView.logic.test.ts+57-7| ... | @@ -1,6 +1,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 | import { afterEach, describe, expect, it, vi } from "vitest"; | 3 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | import { useStore } from "../store"; | 4 | import { type EnvironmentState, useStore } from "../store"; |
| 4 | import { type Thread } from "../types"; | 5 | import { type Thread } from "../types"; |
| 5 | 6 | ||
| 6 | import { | 7 | import { |
| ... | @@ -10,9 +11,12 @@ import { | ... | @@ -10,9 +11,12 @@ import { |
| 10 | deriveComposerSendState, | 11 | deriveComposerSendState, |
| 11 | hasServerAcknowledgedLocalDispatch, | 12 | hasServerAcknowledgedLocalDispatch, |
| 12 | reconcileMountedTerminalThreadIds, | 13 | reconcileMountedTerminalThreadIds, |
| 14 | shouldWriteThreadErrorToCurrentServerThread, | ||
| 13 | waitForStartedServerThread, | 15 | waitForStartedServerThread, |
| 14 | } from "./ChatView.logic"; | 16 | } from "./ChatView.logic"; |
| 15 | 17 | ||
| 18 | const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); | ||
| 19 | |||
| 16 | describe("deriveComposerSendState", () => { | 20 | describe("deriveComposerSendState", () => { |
| 17 | it("treats expired terminal pills as non-sendable content", () => { | 21 | it("treats expired terminal pills as non-sendable content", () => { |
| 18 | const state = deriveComposerSendState({ | 22 | const state = deriveComposerSendState({ |
| ... | @@ -170,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => { | ... | @@ -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 | const makeThread = (input?: { | 207 | const makeThread = (input?: { |
| 174 | id?: ThreadId; | 208 | id?: ThreadId; |
| 175 | latestTurn?: { | 209 | latestTurn?: { |
| ... | @@ -181,6 +215,7 @@ const makeThread = (input?: { | ... | @@ -181,6 +215,7 @@ const makeThread = (input?: { |
| 181 | } | null; | 215 | } | null; |
| 182 | }): Thread => ({ | 216 | }): Thread => ({ |
| 183 | id: input?.id ?? ThreadId.makeUnsafe("thread-1"), | 217 | id: input?.id ?? ThreadId.makeUnsafe("thread-1"), |
| 218 | environmentId: localEnvironmentId, | ||
| 184 | codexThreadId: null, | 219 | codexThreadId: null, |
| 185 | projectId: ProjectId.makeUnsafe("project-1"), | 220 | projectId: ProjectId.makeUnsafe("project-1"), |
| 186 | title: "Thread", | 221 | title: "Thread", |
| ... | @@ -208,11 +243,12 @@ const makeThread = (input?: { | ... | @@ -208,11 +243,12 @@ const makeThread = (input?: { |
| 208 | 243 | ||
| 209 | function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) { | 244 | function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) { |
| 210 | const projectId = ProjectId.makeUnsafe("project-1"); | 245 | const projectId = ProjectId.makeUnsafe("project-1"); |
| 211 | useStore.setState({ | 246 | const environmentState: EnvironmentState = { |
| 212 | projectIds: [projectId], | 247 | projectIds: [projectId], |
| 213 | projectById: { | 248 | projectById: { |
| 214 | [projectId]: { | 249 | [projectId]: { |
| 215 | id: projectId, | 250 | id: projectId, |
| 251 | environmentId: localEnvironmentId, | ||
| 216 | name: "Project", | 252 | name: "Project", |
| 217 | cwd: "/tmp/project", | 253 | cwd: "/tmp/project", |
| 218 | defaultModelSelection: { | 254 | defaultModelSelection: { |
| ... | @@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) | ... | @@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) |
| 233 | thread.id, | 269 | thread.id, |
| 234 | { | 270 | { |
| 235 | id: thread.id, | 271 | id: thread.id, |
| 272 | environmentId: thread.environmentId, | ||
| 236 | codexThreadId: thread.codexThreadId, | 273 | codexThreadId: thread.codexThreadId, |
| 237 | projectId: thread.projectId, | 274 | projectId: thread.projectId, |
| 238 | title: thread.title, | 275 | title: thread.title, |
| ... | @@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) | ... | @@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray<ReturnType<typeof makeThread>>) |
| 301 | ), | 338 | ), |
| 302 | sidebarThreadSummaryById: {}, | 339 | sidebarThreadSummaryById: {}, |
| 303 | bootstrapComplete: true, | 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,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 | it("waits for the thread to start via subscription updates", async () => { | 377 | it("waits for the thread to start via subscription updates", async () => { |
| 333 | const threadId = ThreadId.makeUnsafe("thread-wait"); | 378 | const threadId = ThreadId.makeUnsafe("thread-wait"); |
| 334 | setStoreThreads([makeThread({ id: threadId })]); | 379 | setStoreThreads([makeThread({ id: threadId })]); |
| 335 | 380 | ||
| 336 | const promise = waitForStartedServerThread(threadId, 500); | 381 | const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); |
| 337 | 382 | ||
| 338 | setStoreThreads([ | 383 | setStoreThreads([ |
| 339 | makeThread({ | 384 | makeThread({ |
| ... | @@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => { | ... | @@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => { |
| 376 | return originalSubscribe(listener); | 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 | it("returns false after the timeout when the thread never starts", async () => { | 429 | it("returns false after the timeout when the thread never starts", async () => { |
| ... | @@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => { | ... | @@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => { |
| 384 | 431 | ||
| 385 | const threadId = ThreadId.makeUnsafe("thread-timeout"); | 432 | const threadId = ThreadId.makeUnsafe("thread-timeout"); |
| 386 | setStoreThreads([makeThread({ id: threadId })]); | 433 | setStoreThreads([makeThread({ id: threadId })]); |
| 387 | const promise = waitForStartedServerThread(threadId, 500); | 434 | const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); |
| 388 | 435 | ||
| 389 | await vi.advanceTimersByTimeAsync(500); | 436 | await vi.advanceTimersByTimeAsync(500); |
| 390 | 437 | ||
| ... | @@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 414 | it("does not clear local dispatch before server state changes", () => { | 461 | it("does not clear local dispatch before server state changes", () => { |
| 415 | const localDispatch = createLocalDispatchSnapshot({ | 462 | const localDispatch = createLocalDispatchSnapshot({ |
| 416 | id: ThreadId.makeUnsafe("thread-1"), | 463 | id: ThreadId.makeUnsafe("thread-1"), |
| 464 | environmentId: localEnvironmentId, | ||
| 417 | codexThreadId: null, | 465 | codexThreadId: null, |
| 418 | projectId, | 466 | projectId, |
| 419 | title: "Thread", | 467 | title: "Thread", |
| ... | @@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 450 | it("clears local dispatch when a new turn is already settled", () => { | 498 | it("clears local dispatch when a new turn is already settled", () => { |
| 451 | const localDispatch = createLocalDispatchSnapshot({ | 499 | const localDispatch = createLocalDispatchSnapshot({ |
| 452 | id: ThreadId.makeUnsafe("thread-1"), | 500 | id: ThreadId.makeUnsafe("thread-1"), |
| 501 | environmentId: localEnvironmentId, | ||
| 453 | codexThreadId: null, | 502 | codexThreadId: null, |
| 454 | projectId, | 503 | projectId, |
| 455 | title: "Thread", | 504 | title: "Thread", |
| ... | @@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 495 | it("clears local dispatch when the session changes without an observed running phase", () => { | 544 | it("clears local dispatch when the session changes without an observed running phase", () => { |
| 496 | const localDispatch = createLocalDispatchSnapshot({ | 545 | const localDispatch = createLocalDispatchSnapshot({ |
| 497 | id: ThreadId.makeUnsafe("thread-1"), | 546 | id: ThreadId.makeUnsafe("thread-1"), |
| 547 | environmentId: localEnvironmentId, | ||
| 498 | codexThreadId: null, | 548 | codexThreadId: null, |
| 499 | projectId, | 549 | projectId, |
| 500 | title: "Thread", | 550 | title: "Thread", |
apps/web/src/components/ChatView.logic.ts+36-9| ... | @@ -1,9 +1,16 @@ | ... | @@ -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 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; | 9 | import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; |
| 3 | import { randomUUID } from "~/lib/utils"; | 10 | import { randomUUID } from "~/lib/utils"; |
| 4 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 11 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 5 | import { Schema } from "effect"; | 12 | import { Schema } from "effect"; |
| 6 | import { selectThreadById, useStore } from "../store"; | 13 | import { selectThreadByRef, useStore } from "../store"; |
| 7 | import { | 14 | import { |
| 8 | filterTerminalContextsWithText, | 15 | filterTerminalContextsWithText, |
| 9 | stripInlineTerminalContextPlaceholders, | 16 | stripInlineTerminalContextPlaceholders, |
| ... | @@ -24,6 +31,7 @@ export function buildLocalDraftThread( | ... | @@ -24,6 +31,7 @@ export function buildLocalDraftThread( |
| 24 | ): Thread { | 31 | ): Thread { |
| 25 | return { | 32 | return { |
| 26 | id: threadId, | 33 | id: threadId, |
| 34 | environmentId: draftThread.environmentId, | ||
| 27 | codexThreadId: null, | 35 | codexThreadId: null, |
| 28 | projectId: draftThread.projectId, | 36 | projectId: draftThread.projectId, |
| 29 | title: "New thread", | 37 | title: "New thread", |
| ... | @@ -44,13 +52,32 @@ export function buildLocalDraftThread( | ... | @@ -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 | export function reconcileMountedTerminalThreadIds(input: { | 74 | export function reconcileMountedTerminalThreadIds(input: { |
| 48 | currentThreadIds: ReadonlyArray<ThreadId>; | 75 | currentThreadIds: ReadonlyArray<string>; |
| 49 | openThreadIds: ReadonlyArray<ThreadId>; | 76 | openThreadIds: ReadonlyArray<string>; |
| 50 | activeThreadId: ThreadId | null; | 77 | activeThreadId: string | null; |
| 51 | activeThreadTerminalOpen: boolean; | 78 | activeThreadTerminalOpen: boolean; |
| 52 | maxHiddenThreadCount?: number; | 79 | maxHiddenThreadCount?: number; |
| 53 | }): ThreadId[] { | 80 | }): string[] { |
| 54 | const openThreadIdSet = new Set(input.openThreadIds); | 81 | const openThreadIdSet = new Set(input.openThreadIds); |
| 55 | const hiddenThreadIds = input.currentThreadIds.filter( | 82 | const hiddenThreadIds = input.currentThreadIds.filter( |
| 56 | (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), | 83 | (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), |
| ... | @@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { | ... | @@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { |
| 199 | } | 226 | } |
| 200 | 227 | ||
| 201 | export async function waitForStartedServerThread( | 228 | export async function waitForStartedServerThread( |
| 202 | threadId: ThreadId, | 229 | threadRef: ScopedThreadRef, |
| 203 | timeoutMs = 1_000, | 230 | timeoutMs = 1_000, |
| 204 | ): Promise<boolean> { | 231 | ): Promise<boolean> { |
| 205 | const getThread = () => selectThreadById(threadId)(useStore.getState()); | 232 | const getThread = () => selectThreadByRef(useStore.getState(), threadRef); |
| 206 | const thread = getThread(); | 233 | const thread = getThread(); |
| 207 | 234 | ||
| 208 | if (threadHasStarted(thread)) { | 235 | if (threadHasStarted(thread)) { |
| ... | @@ -225,7 +252,7 @@ export async function waitForStartedServerThread( | ... | @@ -225,7 +252,7 @@ export async function waitForStartedServerThread( |
| 225 | }; | 252 | }; |
| 226 | 253 | ||
| 227 | const unsubscribe = useStore.subscribe((state) => { | 254 | const unsubscribe = useStore.subscribe((state) => { |
| 228 | if (!threadHasStarted(selectThreadById(threadId)(state))) { | 255 | if (!threadHasStarted(selectThreadByRef(state, threadRef))) { |
| 229 | return; | 256 | return; |
| 230 | } | 257 | } |
| 231 | finish(true); | 258 | finish(true); |
apps/web/src/components/ChatView.tsx+522-254| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | type ApprovalRequestId, | 2 | type ApprovalRequestId, |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | type ClaudeCodeEffort, | 4 | type ClaudeCodeEffort, |
| 5 | type EnvironmentId, | ||
| 5 | type MessageId, | 6 | type MessageId, |
| 6 | type ModelSelection, | 7 | type ModelSelection, |
| 7 | type ProjectScript, | 8 | type ProjectScript, |
| ... | @@ -12,6 +13,7 @@ import { | ... | @@ -12,6 +13,7 @@ import { |
| 12 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 13 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 13 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 14 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 14 | type ServerProvider, | 15 | type ServerProvider, |
| 16 | type ScopedThreadRef, | ||
| 15 | type ThreadId, | 17 | type ThreadId, |
| 16 | type TurnId, | 18 | type TurnId, |
| 17 | type KeybindingCommand, | 19 | type KeybindingCommand, |
| ... | @@ -20,6 +22,12 @@ import { | ... | @@ -20,6 +22,12 @@ import { |
| 20 | RuntimeMode, | 22 | RuntimeMode, |
| 21 | TerminalOpenInput, | 23 | TerminalOpenInput, |
| 22 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 25 | import { | ||
| 26 | parseScopedThreadKey, | ||
| 27 | scopedThreadKey, | ||
| 28 | scopeProjectRef, | ||
| 29 | scopeThreadRef, | ||
| 30 | } from "@t3tools/client-runtime"; | ||
| 23 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; | 31 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; |
| 24 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 32 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 25 | import { truncate } from "@t3tools/shared/String"; | 33 | import { truncate } from "@t3tools/shared/String"; |
| ... | @@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr | ... | @@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr |
| 27 | import { useQuery } from "@tanstack/react-query"; | 35 | import { useQuery } from "@tanstack/react-query"; |
| 28 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 36 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 29 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 37 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 38 | import { useShallow } from "zustand/react/shallow"; | ||
| 30 | import { useGitStatus } from "~/lib/gitStatusState"; | 39 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 31 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 40 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 41 | import { readEnvironmentApi } from "../environmentApi"; | ||
| 32 | import { isElectron } from "../env"; | 42 | import { isElectron } from "../env"; |
| 43 | import { readLocalApi } from "../localApi"; | ||
| 33 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 44 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 34 | import { | 45 | import { |
| 35 | clampCollapsedComposerCursor, | 46 | clampCollapsedComposerCursor, |
| ... | @@ -64,8 +75,8 @@ import { | ... | @@ -64,8 +75,8 @@ import { |
| 64 | togglePendingUserInputOptionSelection, | 75 | togglePendingUserInputOptionSelection, |
| 65 | type PendingUserInputDraftAnswer, | 76 | type PendingUserInputDraftAnswer, |
| 66 | } from "../pendingUserInput"; | 77 | } from "../pendingUserInput"; |
| 67 | import { useStore } from "../store"; | 78 | import { selectThreadsAcrossEnvironments, useStore } from "../store"; |
| 68 | import { createThreadSelector } from "../storeSelectors"; | 79 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 69 | import { useUiStateStore } from "../uiStateStore"; | 80 | import { useUiStateStore } from "../uiStateStore"; |
| 70 | import { | 81 | import { |
| 71 | buildPlanImplementationThreadTitle, | 82 | buildPlanImplementationThreadTitle, |
| ... | @@ -114,8 +125,7 @@ import { | ... | @@ -114,8 +125,7 @@ import { |
| 114 | projectScriptIdFromCommand, | 125 | projectScriptIdFromCommand, |
| 115 | } from "~/projectScripts"; | 126 | } from "~/projectScripts"; |
| 116 | import { SidebarTrigger } from "./ui/sidebar"; | 127 | import { SidebarTrigger } from "./ui/sidebar"; |
| 117 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; | 128 | import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 118 | import { readNativeApi } from "~/nativeApi"; | ||
| 119 | import { | 129 | import { |
| 120 | getProviderModelCapabilities, | 130 | getProviderModelCapabilities, |
| 121 | getProviderModels, | 131 | getProviderModels, |
| ... | @@ -124,6 +134,8 @@ import { | ... | @@ -124,6 +134,8 @@ import { |
| 124 | import { useSettings } from "../hooks/useSettings"; | 134 | import { useSettings } from "../hooks/useSettings"; |
| 125 | import { resolveAppModelSelection } from "../modelSelection"; | 135 | import { resolveAppModelSelection } from "../modelSelection"; |
| 126 | import { isTerminalFocused } from "../lib/terminalFocus"; | 136 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 137 | import { deriveLogicalProjectKey } from "../logicalProject"; | ||
| 138 | import { buildDraftThreadRouteParams } from "../threadRoutes"; | ||
| 127 | import { | 139 | import { |
| 128 | type ComposerImageAttachment, | 140 | type ComposerImageAttachment, |
| 129 | type DraftThreadEnvMode, | 141 | type DraftThreadEnvMode, |
| ... | @@ -131,6 +143,7 @@ import { | ... | @@ -131,6 +143,7 @@ import { |
| 131 | useComposerDraftStore, | 143 | useComposerDraftStore, |
| 132 | useEffectiveComposerModelState, | 144 | useEffectiveComposerModelState, |
| 133 | useComposerThreadDraft, | 145 | useComposerThreadDraft, |
| 146 | type DraftId, | ||
| 134 | } from "../composerDraftStore"; | 147 | } from "../composerDraftStore"; |
| 135 | import { | 148 | import { |
| 136 | appendTerminalContextsToPrompt, | 149 | appendTerminalContextsToPrompt, |
| ... | @@ -187,6 +200,7 @@ import { | ... | @@ -187,6 +200,7 @@ import { |
| 187 | reconcileMountedTerminalThreadIds, | 200 | reconcileMountedTerminalThreadIds, |
| 188 | revokeBlobPreviewUrl, | 201 | revokeBlobPreviewUrl, |
| 189 | revokeUserMessagePreviewUrls, | 202 | revokeUserMessagePreviewUrls, |
| 203 | shouldWriteThreadErrorToCurrentServerThread, | ||
| 190 | threadHasStarted, | 204 | threadHasStarted, |
| 191 | waitForStartedServerThread, | 205 | waitForStartedServerThread, |
| 192 | } from "./ChatView.logic"; | 206 | } from "./ChatView.logic"; |
| ... | @@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES | ... | @@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES |
| 203 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 217 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 204 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 218 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 205 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | 219 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 220 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; | ||
| 206 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | 221 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 207 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 222 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 208 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 223 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| ... | @@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw | ... | @@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw |
| 210 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; | 225 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; |
| 211 | 226 | ||
| 212 | function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { | 227 | function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { |
| 213 | const threadShellById = useStore((state) => state.threadShellById); | 228 | return useStore( |
| 214 | const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId); | 229 | useMemo(() => { |
| 215 | const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId); | 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( | 276 | if (!shell) { |
| 218 | () => | 277 | const previous = previousEntries.get(threadId); |
| 219 | threadIds.flatMap((threadId) => { | 278 | if ( |
| 220 | if (!threadShellById[threadId]) { | 279 | previous && |
| 221 | return []; | 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 = | 327 | if (!changed && previousResult.length === nextResult.length) { |
| 225 | proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { | 328 | return previousResult; |
| 226 | const plan = proposedPlanByThreadId[threadId]?.[planId]; | 329 | } |
| 227 | return plan ? [plan] : []; | ||
| 228 | }) ?? []; | ||
| 229 | 330 | ||
| 230 | return [{ id: threadId, proposedPlans }]; | 331 | previousThreadIds = threadIds; |
| 231 | }), | 332 | previousEntries = nextEntries; |
| 232 | [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById], | 333 | previousResult = nextResult; |
| 334 | return nextResult; | ||
| 335 | }; | ||
| 336 | }, [threadIds]), | ||
| 233 | ); | 337 | ); |
| 234 | } | 338 | } |
| 235 | 339 | ||
| ... | @@ -278,9 +382,19 @@ const terminalContextIdListsEqual = ( | ... | @@ -278,9 +382,19 @@ const terminalContextIdListsEqual = ( |
| 278 | ): boolean => | 382 | ): boolean => |
| 279 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); | 383 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); |
| 280 | 384 | ||
| 281 | interface ChatViewProps { | 385 | type ChatViewProps = |
| 282 | threadId: ThreadId; | 386 | | { |
| 283 | } | 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 | interface TerminalLaunchContext { | 399 | interface TerminalLaunchContext { |
| 286 | threadId: ThreadId; | 400 | threadId: ThreadId; |
| ... | @@ -358,6 +472,7 @@ function useLocalDispatchState(input: { | ... | @@ -358,6 +472,7 @@ function useLocalDispatchState(input: { |
| 358 | } | 472 | } |
| 359 | 473 | ||
| 360 | interface PersistentThreadTerminalDrawerProps { | 474 | interface PersistentThreadTerminalDrawerProps { |
| 475 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; | ||
| 361 | threadId: ThreadId; | 476 | threadId: ThreadId; |
| 362 | visible: boolean; | 477 | visible: boolean; |
| 363 | launchContext: PersistentTerminalLaunchContext | null; | 478 | launchContext: PersistentTerminalLaunchContext | null; |
| ... | @@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps { | ... | @@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps { |
| 369 | } | 484 | } |
| 370 | 485 | ||
| 371 | function PersistentThreadTerminalDrawer({ | 486 | function PersistentThreadTerminalDrawer({ |
| 487 | threadRef, | ||
| 372 | threadId, | 488 | threadId, |
| 373 | visible, | 489 | visible, |
| 374 | launchContext, | 490 | launchContext, |
| ... | @@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({ | ... | @@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({ |
| 378 | closeShortcutLabel, | 494 | closeShortcutLabel, |
| 379 | onAddTerminalContext, | 495 | onAddTerminalContext, |
| 380 | }: PersistentThreadTerminalDrawerProps) { | 496 | }: PersistentThreadTerminalDrawerProps) { |
| 381 | const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); | 497 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); |
| 382 | const draftThread = useComposerDraftStore( | 498 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); |
| 383 | (store) => store.draftThreadsByThreadId[threadId] ?? null, | 499 | const projectRef = serverThread |
| 384 | ); | 500 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 385 | const project = useStore((state) => | 501 | : draftThread |
| 386 | serverThread?.projectId | 502 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 387 | ? state.projectById[serverThread.projectId] | 503 | : null; |
| 388 | : draftThread?.projectId | 504 | const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); |
| 389 | ? state.projectById[draftThread.projectId] | ||
| 390 | : undefined, | ||
| 391 | ); | ||
| 392 | const terminalState = useTerminalStateStore((state) => | 505 | const terminalState = useTerminalStateStore((state) => |
| 393 | selectThreadTerminalState(state.terminalStateByThreadId, threadId), | 506 | selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), |
| 394 | ); | 507 | ); |
| 395 | const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); | 508 | const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); |
| 396 | const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); | 509 | const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); |
| ... | @@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({ | ... | @@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({ |
| 436 | 549 | ||
| 437 | const setTerminalHeight = useCallback( | 550 | const setTerminalHeight = useCallback( |
| 438 | (height: number) => { | 551 | (height: number) => { |
| 439 | storeSetTerminalHeight(threadId, height); | 552 | storeSetTerminalHeight(threadRef, height); |
| 440 | }, | 553 | }, |
| 441 | [storeSetTerminalHeight, threadId], | 554 | [storeSetTerminalHeight, threadRef], |
| 442 | ); | 555 | ); |
| 443 | 556 | ||
| 444 | const splitTerminal = useCallback(() => { | 557 | const splitTerminal = useCallback(() => { |
| 445 | storeSplitTerminal(threadId, `terminal-${randomUUID()}`); | 558 | storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); |
| 446 | bumpFocusRequestId(); | 559 | bumpFocusRequestId(); |
| 447 | }, [bumpFocusRequestId, storeSplitTerminal, threadId]); | 560 | }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); |
| 448 | 561 | ||
| 449 | const createNewTerminal = useCallback(() => { | 562 | const createNewTerminal = useCallback(() => { |
| 450 | storeNewTerminal(threadId, `terminal-${randomUUID()}`); | 563 | storeNewTerminal(threadRef, `terminal-${randomUUID()}`); |
| 451 | bumpFocusRequestId(); | 564 | bumpFocusRequestId(); |
| 452 | }, [bumpFocusRequestId, storeNewTerminal, threadId]); | 565 | }, [bumpFocusRequestId, storeNewTerminal, threadRef]); |
| 453 | 566 | ||
| 454 | const activateTerminal = useCallback( | 567 | const activateTerminal = useCallback( |
| 455 | (terminalId: string) => { | 568 | (terminalId: string) => { |
| 456 | storeSetActiveTerminal(threadId, terminalId); | 569 | storeSetActiveTerminal(threadRef, terminalId); |
| 457 | bumpFocusRequestId(); | 570 | bumpFocusRequestId(); |
| 458 | }, | 571 | }, |
| 459 | [bumpFocusRequestId, storeSetActiveTerminal, threadId], | 572 | [bumpFocusRequestId, storeSetActiveTerminal, threadRef], |
| 460 | ); | 573 | ); |
| 461 | 574 | ||
| 462 | const closeTerminal = useCallback( | 575 | const closeTerminal = useCallback( |
| 463 | (terminalId: string) => { | 576 | (terminalId: string) => { |
| 464 | const api = readNativeApi(); | 577 | const api = readEnvironmentApi(threadRef.environmentId); |
| 465 | if (!api) return; | 578 | if (!api) return; |
| 466 | const isFinalTerminal = terminalState.terminalIds.length <= 1; | 579 | const isFinalTerminal = terminalState.terminalIds.length <= 1; |
| 467 | const fallbackExitWrite = () => | 580 | const fallbackExitWrite = () => |
| ... | @@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({ | ... | @@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({ |
| 482 | void fallbackExitWrite(); | 595 | void fallbackExitWrite(); |
| 483 | } | 596 | } |
| 484 | 597 | ||
| 485 | storeCloseTerminal(threadId, terminalId); | 598 | storeCloseTerminal(threadRef, terminalId); |
| 486 | bumpFocusRequestId(); | 599 | bumpFocusRequestId(); |
| 487 | }, | 600 | }, |
| 488 | [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], | 601 | [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], |
| 489 | ); | 602 | ); |
| 490 | 603 | ||
| 491 | const handleAddTerminalContext = useCallback( | 604 | const handleAddTerminalContext = useCallback( |
| ... | @@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({ | ... | @@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({ |
| 505 | return ( | 618 | return ( |
| 506 | <div className={visible ? undefined : "hidden"}> | 619 | <div className={visible ? undefined : "hidden"}> |
| 507 | <ThreadTerminalDrawer | 620 | <ThreadTerminalDrawer |
| 621 | threadRef={threadRef} | ||
| 508 | threadId={threadId} | 622 | threadId={threadId} |
| 509 | cwd={cwd} | 623 | cwd={cwd} |
| 510 | worktreePath={effectiveWorktreePath} | 624 | worktreePath={effectiveWorktreePath} |
| ... | @@ -530,12 +644,27 @@ function PersistentThreadTerminalDrawer({ | ... | @@ -530,12 +644,27 @@ function PersistentThreadTerminalDrawer({ |
| 530 | ); | 644 | ); |
| 531 | } | 645 | } |
| 532 | 646 | ||
| 533 | export default function ChatView({ threadId }: ChatViewProps) { | 647 | export default function ChatView(props: ChatViewProps) { |
| 534 | const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); | 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 | const setStoreThreadError = useStore((store) => store.setError); | 662 | const setStoreThreadError = useStore((store) => store.setError); |
| 536 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); | 663 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 537 | const activeThreadLastVisitedAt = useUiStateStore( | 664 | const activeThreadLastVisitedAt = useUiStateStore((store) => |
| 538 | (store) => store.threadLastVisitedAtById[threadId], | 665 | routeKind === "server" |
| 666 | ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))] | ||
| 667 | : undefined, | ||
| 539 | ); | 668 | ); |
| 540 | const settings = useSettings(); | 669 | const settings = useSettings(); |
| 541 | const setStickyComposerModelSelection = useComposerDraftStore( | 670 | const setStickyComposerModelSelection = useComposerDraftStore( |
| ... | @@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 548 | select: (params) => parseDiffRouteSearch(params), | 677 | select: (params) => parseDiffRouteSearch(params), |
| 549 | }); | 678 | }); |
| 550 | const { resolvedTheme } = useTheme(); | 679 | const { resolvedTheme } = useTheme(); |
| 551 | const composerDraft = useComposerThreadDraft(threadId); | 680 | const composerDraft = useComposerThreadDraft(composerDraftTarget); |
| 552 | const prompt = composerDraft.prompt; | 681 | const prompt = composerDraft.prompt; |
| 553 | const composerImages = composerDraft.images; | 682 | const composerImages = composerDraft.images; |
| 554 | const composerTerminalContexts = composerDraft.terminalContexts; | 683 | const composerTerminalContexts = composerDraft.terminalContexts; |
| ... | @@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 591 | ); | 720 | ); |
| 592 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); | 721 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); |
| 593 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 722 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 594 | const getDraftThreadByProjectId = useComposerDraftStore( | 723 | const getDraftSessionByLogicalProjectKey = useComposerDraftStore( |
| 595 | (store) => store.getDraftThreadByProjectId, | 724 | (store) => store.getDraftSessionByLogicalProjectKey, |
| 596 | ); | 725 | ); |
| 597 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | 726 | const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); |
| 598 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); | 727 | const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); |
| 599 | const clearProjectDraftThreadId = useComposerDraftStore( | 728 | const setLogicalProjectDraftThreadId = useComposerDraftStore( |
| 600 | (store) => store.clearProjectDraftThreadId, | 729 | (store) => store.setLogicalProjectDraftThreadId, |
| 601 | ); | 730 | ); |
| 602 | const draftThread = useComposerDraftStore( | 731 | const draftThread = useComposerDraftStore((store) => |
| 603 | (store) => store.draftThreadsByThreadId[threadId] ?? null, | 732 | routeKind === "server" |
| 733 | ? store.getDraftSessionByRef(routeThreadRef) | ||
| 734 | : draftId | ||
| 735 | ? store.getDraftSession(draftId) | ||
| 736 | : null, | ||
| 604 | ); | 737 | ); |
| 605 | const promptRef = useRef(prompt); | 738 | const promptRef = useRef(prompt); |
| 606 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); | 739 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| ... | @@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 610 | const optimisticUserMessagesRef = useRef(optimisticUserMessages); | 743 | const optimisticUserMessagesRef = useRef(optimisticUserMessages); |
| 611 | optimisticUserMessagesRef.current = optimisticUserMessages; | 744 | optimisticUserMessagesRef.current = optimisticUserMessages; |
| 612 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts); | 745 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts); |
| 613 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< | 746 | const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< |
| 614 | Record<ThreadId, string | null> | 747 | Record<string, string | null> |
| 615 | >({}); | 748 | >({}); |
| 616 | const [isConnecting, _setIsConnecting] = useState(false); | 749 | const [isConnecting, _setIsConnecting] = useState(false); |
| 617 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | 750 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| ... | @@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 689 | setMessagesScrollElement(element); | 822 | setMessagesScrollElement(element); |
| 690 | }, []); | 823 | }, []); |
| 691 | 824 | ||
| 692 | const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); | 825 | const terminalState = useTerminalStateStore((state) => |
| 693 | const terminalState = useMemo( | 826 | selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), |
| 694 | () => selectThreadTerminalState(terminalStateByThreadId, threadId), | ||
| 695 | [terminalStateByThreadId, threadId], | ||
| 696 | ); | 827 | ); |
| 697 | const openTerminalThreadIds = useMemo( | 828 | const openTerminalThreadKeys = useTerminalStateStore( |
| 698 | () => | 829 | useShallow((state) => |
| 699 | Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => | 830 | Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => |
| 700 | nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], | 831 | nextTerminalState.terminalOpen ? [nextThreadKey] : [], |
| 701 | ), | 832 | ), |
| 702 | [terminalStateByThreadId], | 833 | ), |
| 703 | ); | 834 | ); |
| 704 | const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); | 835 | const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); |
| 705 | const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); | 836 | const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); |
| 706 | const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); | 837 | const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); |
| 707 | const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); | 838 | const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); |
| 708 | const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); | 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 | const storeServerTerminalLaunchContext = useTerminalStateStore( | 847 | const storeServerTerminalLaunchContext = useTerminalStateStore( |
| 711 | (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, | 848 | (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, |
| 712 | ); | 849 | ); |
| 713 | const storeClearTerminalLaunchContext = useTerminalStateStore( | 850 | const storeClearTerminalLaunchContext = useTerminalStateStore( |
| 714 | (s) => s.clearTerminalLaunchContext, | 851 | (s) => s.clearTerminalLaunchContext, |
| 715 | ); | 852 | ); |
| 716 | const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); | 853 | const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); |
| 717 | const draftThreadIds = useMemo( | 854 | const draftThreadKeys = useMemo( |
| 718 | () => Object.keys(draftThreadsByThreadId) as ThreadId[], | 855 | () => |
| 719 | [draftThreadsByThreadId], | 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 | const setPrompt = useCallback( | 863 | const setPrompt = useCallback( |
| 724 | (nextPrompt: string) => { | 864 | (nextPrompt: string) => { |
| 725 | setComposerDraftPrompt(threadId, nextPrompt); | 865 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); |
| 726 | }, | 866 | }, |
| 727 | [setComposerDraftPrompt, threadId], | 867 | [composerDraftTarget, setComposerDraftPrompt], |
| 728 | ); | 868 | ); |
| 729 | const addComposerImage = useCallback( | 869 | const addComposerImage = useCallback( |
| 730 | (image: ComposerImageAttachment) => { | 870 | (image: ComposerImageAttachment) => { |
| 731 | addComposerDraftImage(threadId, image); | 871 | addComposerDraftImage(composerDraftTarget, image); |
| 732 | }, | 872 | }, |
| 733 | [addComposerDraftImage, threadId], | 873 | [addComposerDraftImage, composerDraftTarget], |
| 734 | ); | 874 | ); |
| 735 | const addComposerImagesToDraft = useCallback( | 875 | const addComposerImagesToDraft = useCallback( |
| 736 | (images: ComposerImageAttachment[]) => { | 876 | (images: ComposerImageAttachment[]) => { |
| 737 | addComposerDraftImages(threadId, images); | 877 | addComposerDraftImages(composerDraftTarget, images); |
| 738 | }, | 878 | }, |
| 739 | [addComposerDraftImages, threadId], | 879 | [addComposerDraftImages, composerDraftTarget], |
| 740 | ); | 880 | ); |
| 741 | const addComposerTerminalContextsToDraft = useCallback( | 881 | const addComposerTerminalContextsToDraft = useCallback( |
| 742 | (contexts: TerminalContextDraft[]) => { | 882 | (contexts: TerminalContextDraft[]) => { |
| 743 | addComposerDraftTerminalContexts(threadId, contexts); | 883 | addComposerDraftTerminalContexts(composerDraftTarget, contexts); |
| 744 | }, | 884 | }, |
| 745 | [addComposerDraftTerminalContexts, threadId], | 885 | [addComposerDraftTerminalContexts, composerDraftTarget], |
| 746 | ); | 886 | ); |
| 747 | const removeComposerImageFromDraft = useCallback( | 887 | const removeComposerImageFromDraft = useCallback( |
| 748 | (imageId: string) => { | 888 | (imageId: string) => { |
| 749 | removeComposerDraftImage(threadId, imageId); | 889 | removeComposerDraftImage(composerDraftTarget, imageId); |
| 750 | }, | 890 | }, |
| 751 | [removeComposerDraftImage, threadId], | 891 | [composerDraftTarget, removeComposerDraftImage], |
| 752 | ); | 892 | ); |
| 753 | const removeComposerTerminalContextFromDraft = useCallback( | 893 | const removeComposerTerminalContextFromDraft = useCallback( |
| 754 | (contextId: string) => { | 894 | (contextId: string) => { |
| ... | @@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 761 | const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); | 901 | const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); |
| 762 | promptRef.current = nextPrompt.prompt; | 902 | promptRef.current = nextPrompt.prompt; |
| 763 | setPrompt(nextPrompt.prompt); | 903 | setPrompt(nextPrompt.prompt); |
| 764 | removeComposerDraftTerminalContext(threadId, contextId); | 904 | removeComposerDraftTerminalContext(composerDraftTarget, contextId); |
| 765 | setComposerCursor(nextPrompt.cursor); | 905 | setComposerCursor(nextPrompt.cursor); |
| 766 | setComposerTrigger( | 906 | setComposerTrigger( |
| 767 | detectComposerTrigger( | 907 | detectComposerTrigger( |
| ... | @@ -770,13 +910,19 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -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) => | 916 | const fallbackDraftProjectRef = draftThread |
| 777 | draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, | 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 | const localDraftThread = useMemo( | 926 | const localDraftThread = useMemo( |
| 781 | () => | 927 | () => |
| 782 | draftThread | 928 | draftThread |
| ... | @@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 792 | : undefined, | 938 | : undefined, |
| 793 | [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], | 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 | const runtimeMode = | 943 | const runtimeMode = |
| 797 | composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; | 944 | composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 798 | const interactionMode = | 945 | const interactionMode = |
| 799 | composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; | 946 | composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; |
| 800 | const isServerThread = serverThread !== undefined; | ||
| 801 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; | 947 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 802 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; | 948 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 803 | const diffOpen = rawSearch.diff === "1"; | 949 | const diffOpen = rawSearch.diff === "1"; |
| 804 | const activeThreadId = activeThread?.id ?? null; | 950 | const activeThreadId = activeThread?.id ?? null; |
| 805 | const existingOpenTerminalThreadIds = useMemo(() => { | 951 | const activeThreadRef = useMemo( |
| 806 | const existingThreadIds = new Set<ThreadId>([...serverThreadIds, ...draftThreadIds]); | 952 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), |
| 807 | return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); | 953 | [activeThread], |
| 808 | }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); | 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 | const activeLatestTurn = activeThread?.latestTurn ?? null; | 960 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 810 | const threadPlanCatalog = useThreadPlanCatalog( | 961 | const threadPlanCatalog = useThreadPlanCatalog( |
| 811 | useMemo(() => { | 962 | useMemo(() => { |
| ... | @@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 825 | [activeThread?.activities], | 976 | [activeThread?.activities], |
| 826 | ); | 977 | ); |
| 827 | useEffect(() => { | 978 | useEffect(() => { |
| 828 | setMountedTerminalThreadIds((currentThreadIds) => { | 979 | setMountedTerminalThreadKeys((currentThreadIds) => { |
| 829 | const nextThreadIds = reconcileMountedTerminalThreadIds({ | 980 | const nextThreadIds = reconcileMountedTerminalThreadIds({ |
| 830 | currentThreadIds, | 981 | currentThreadIds, |
| 831 | openThreadIds: existingOpenTerminalThreadIds, | 982 | openThreadIds: existingOpenTerminalThreadKeys, |
| 832 | activeThreadId, | 983 | activeThreadId: activeThreadKey, |
| 833 | activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), | 984 | activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), |
| 834 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 985 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 835 | }); | 986 | }); |
| 836 | return currentThreadIds.length === nextThreadIds.length && | 987 | return currentThreadIds.length === nextThreadIds.length && |
| ... | @@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 838 | ? currentThreadIds | 989 | ? currentThreadIds |
| 839 | : nextThreadIds; | 990 | : nextThreadIds; |
| 840 | }); | 991 | }); |
| 841 | }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); | 992 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); |
| 842 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 993 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 843 | const activeProject = useStore((state) => | 994 | const activeProjectRef = activeThread |
| 844 | activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, | 995 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| 996 | : null; | ||
| 997 | const activeProject = useStore( | ||
| 998 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), | ||
| 845 | ); | 999 | ); |
| 846 | 1000 | ||
| 847 | const openPullRequestDialog = useCallback( | 1001 | const openPullRequestDialog = useCallback( |
| ... | @@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 867 | if (!activeProject) { | 1021 | if (!activeProject) { |
| 868 | throw new Error("No active project is available for this pull request."); | 1022 | throw new Error("No active project is available for this pull request."); |
| 869 | } | 1023 | } |
| 870 | const storedDraftThread = getDraftThreadByProjectId(activeProject.id); | 1024 | const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); |
| 871 | if (storedDraftThread) { | 1025 | const logicalProjectKey = deriveLogicalProjectKey(activeProject); |
| 872 | setDraftThreadContext(storedDraftThread.threadId, input); | 1026 | const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); |
| 873 | setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); | 1027 | if (storedDraftSession) { |
| 874 | if (storedDraftThread.threadId !== threadId) { | 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 | await navigate({ | 1039 | await navigate({ |
| 876 | to: "/$threadId", | 1040 | to: "/draft/$draftId", |
| 877 | params: { threadId: storedDraftThread.threadId }, | 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); | 1047 | const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; |
| 884 | if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { | 1048 | if ( |
| 885 | setDraftThreadContext(threadId, input); | 1049 | !isServerThread && |
| 886 | setProjectDraftThreadId(activeProject.id, threadId, input); | 1050 | activeDraftSession?.logicalProjectKey === logicalProjectKey && |
| 887 | return threadId; | 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 | const nextThreadId = newThreadId(); | 1065 | const nextThreadId = newThreadId(); |
| 892 | setProjectDraftThreadId(activeProject.id, nextThreadId, { | 1066 | setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { |
| 1067 | threadId: nextThreadId, | ||
| 893 | createdAt: new Date().toISOString(), | 1068 | createdAt: new Date().toISOString(), |
| 894 | runtimeMode: DEFAULT_RUNTIME_MODE, | 1069 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 895 | interactionMode: DEFAULT_INTERACTION_MODE, | 1070 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 896 | ...input, | 1071 | ...input, |
| 897 | }); | 1072 | }); |
| 898 | await navigate({ | 1073 | await navigate({ |
| 899 | to: "/$threadId", | 1074 | to: "/draft/$draftId", |
| 900 | params: { threadId: nextThreadId }, | 1075 | params: buildDraftThreadRouteParams(nextDraftId), |
| 901 | }); | 1076 | }); |
| 902 | return nextThreadId; | 1077 | return nextThreadId; |
| 903 | }, | 1078 | }, |
| 904 | [ | 1079 | [ |
| 905 | activeProject, | 1080 | activeProject, |
| 906 | clearProjectDraftThreadId, | 1081 | draftId, |
| 907 | getDraftThread, | 1082 | getDraftSession, |
| 908 | getDraftThreadByProjectId, | 1083 | getDraftSessionByLogicalProjectKey, |
| 909 | isServerThread, | 1084 | isServerThread, |
| 910 | navigate, | 1085 | navigate, |
| 1086 | routeKind, | ||
| 911 | setDraftThreadContext, | 1087 | setDraftThreadContext, |
| 912 | setProjectDraftThreadId, | 1088 | setLogicalProjectDraftThreadId, |
| 913 | threadId, | ||
| 914 | ], | 1089 | ], |
| 915 | ); | 1090 | ); |
| 916 | 1091 | ||
| ... | @@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 934 | const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; | 1109 | const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; |
| 935 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; | 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 | activeLatestTurn?.completedAt, | 1114 | activeLatestTurn?.completedAt, |
| 940 | activeThreadLastVisitedAt, | 1115 | activeThreadLastVisitedAt, |
| 941 | latestTurnSettled, | 1116 | latestTurnSettled, |
| 942 | markThreadVisited, | 1117 | markThreadVisited, |
| 1118 | serverThread?.environmentId, | ||
| 943 | serverThread?.id, | 1119 | serverThread?.id, |
| 944 | ]); | 1120 | ]); |
| 945 | 1121 | ||
| ... | @@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 959 | ); | 1135 | ); |
| 960 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; | 1136 | const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; |
| 961 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ | 1137 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 962 | threadId, | 1138 | threadRef: composerDraftTarget, |
| 963 | providers: providerStatuses, | 1139 | providers: providerStatuses, |
| 964 | selectedProvider, | 1140 | selectedProvider, |
| 965 | threadModelSelection: activeThread?.modelSelection, | 1141 | threadModelSelection: activeThread?.modelSelection, |
| ... | @@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1358 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 1534 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 1359 | ); | 1535 | ); |
| 1360 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | 1536 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1361 | const gitStatusQuery = useGitStatus(gitCwd); | 1537 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); |
| 1362 | const keybindings = useServerKeybindings(); | 1538 | const keybindings = useServerKeybindings(); |
| 1363 | const availableEditors = useServerAvailableEditors(); | 1539 | const availableEditors = useServerAvailableEditors(); |
| 1364 | const modelOptionsByProvider = useMemo( | 1540 | const modelOptionsByProvider = useMemo( |
| ... | @@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1394 | ); | 1570 | ); |
| 1395 | const workspaceEntriesQuery = useQuery( | 1571 | const workspaceEntriesQuery = useQuery( |
| 1396 | projectSearchEntriesQueryOptions({ | 1572 | projectSearchEntriesQueryOptions({ |
| 1573 | environmentId, | ||
| 1397 | cwd: gitCwd, | 1574 | cwd: gitCwd, |
| 1398 | query: effectivePathQuery, | 1575 | query: effectivePathQuery, |
| 1399 | enabled: isPathTrigger, | 1576 | enabled: isPathTrigger, |
| ... | @@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1531 | [keybindings, nonTerminalShortcutLabelOptions], | 1708 | [keybindings, nonTerminalShortcutLabelOptions], |
| 1532 | ); | 1709 | ); |
| 1533 | const onToggleDiff = useCallback(() => { | 1710 | const onToggleDiff = useCallback(() => { |
| 1711 | if (!isServerThread) { | ||
| 1712 | return; | ||
| 1713 | } | ||
| 1534 | void navigate({ | 1714 | void navigate({ |
| 1535 | to: "/$threadId", | 1715 | to: "/$environmentId/$threadId", |
| 1536 | params: { threadId }, | 1716 | params: { |
| 1717 | environmentId, | ||
| 1718 | threadId, | ||
| 1719 | }, | ||
| 1537 | replace: true, | 1720 | replace: true, |
| 1538 | search: (previous) => { | 1721 | search: (previous) => { |
| 1539 | const rest = stripDiffSearchParams(previous); | 1722 | const rest = stripDiffSearchParams(previous); |
| 1540 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; | 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 | const envLocked = Boolean( | 1728 | const envLocked = Boolean( |
| 1546 | activeThread && | 1729 | activeThread && |
| ... | @@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1561 | (targetThreadId: ThreadId | null, error: string | null) => { | 1744 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 1562 | if (!targetThreadId) return; | 1745 | if (!targetThreadId) return; |
| 1563 | const nextError = sanitizeThreadErrorMessage(error); | 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 | setStoreThreadError(targetThreadId, nextError); | 1753 | setStoreThreadError(targetThreadId, nextError); |
| 1566 | return; | 1754 | return; |
| 1567 | } | 1755 | } |
| 1568 | setLocalDraftErrorsByThreadId((existing) => { | 1756 | const localDraftErrorKey = draftId ?? targetThreadId; |
| 1569 | if ((existing[targetThreadId] ?? null) === nextError) { | 1757 | setLocalDraftErrorsByDraftId((existing) => { |
| 1758 | if ((existing[localDraftErrorKey] ?? null) === nextError) { | ||
| 1570 | return existing; | 1759 | return existing; |
| 1571 | } | 1760 | } |
| 1572 | return { | 1761 | return { |
| 1573 | ...existing, | 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 | const focusComposer = useCallback(() => { | 1770 | const focusComposer = useCallback(() => { |
| ... | @@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1606 | insertion.cursor, | 1795 | insertion.cursor, |
| 1607 | ); | 1796 | ); |
| 1608 | const inserted = insertComposerDraftTerminalContext( | 1797 | const inserted = insertComposerDraftTerminalContext( |
| 1609 | activeThread.id, | 1798 | scopeThreadRef(activeThread.environmentId, activeThread.id), |
| 1610 | insertion.prompt, | 1799 | insertion.prompt, |
| 1611 | { | 1800 | { |
| 1612 | id: randomUUID(), | 1801 | id: randomUUID(), |
| ... | @@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1630 | ); | 1819 | ); |
| 1631 | const setTerminalOpen = useCallback( | 1820 | const setTerminalOpen = useCallback( |
| 1632 | (open: boolean) => { | 1821 | (open: boolean) => { |
| 1633 | if (!activeThreadId) return; | 1822 | if (!activeThreadRef) return; |
| 1634 | storeSetTerminalOpen(activeThreadId, open); | 1823 | storeSetTerminalOpen(activeThreadRef, open); |
| 1635 | }, | 1824 | }, |
| 1636 | [activeThreadId, storeSetTerminalOpen], | 1825 | [activeThreadRef, storeSetTerminalOpen], |
| 1637 | ); | 1826 | ); |
| 1638 | const toggleTerminalVisibility = useCallback(() => { | 1827 | const toggleTerminalVisibility = useCallback(() => { |
| 1639 | if (!activeThreadId) return; | 1828 | if (!activeThreadRef) return; |
| 1640 | setTerminalOpen(!terminalState.terminalOpen); | 1829 | setTerminalOpen(!terminalState.terminalOpen); |
| 1641 | }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); | 1830 | }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); |
| 1642 | const splitTerminal = useCallback(() => { | 1831 | const splitTerminal = useCallback(() => { |
| 1643 | if (!activeThreadId || hasReachedSplitLimit) return; | 1832 | if (!activeThreadRef || hasReachedSplitLimit) return; |
| 1644 | const terminalId = `terminal-${randomUUID()}`; | 1833 | const terminalId = `terminal-${randomUUID()}`; |
| 1645 | storeSplitTerminal(activeThreadId, terminalId); | 1834 | storeSplitTerminal(activeThreadRef, terminalId); |
| 1646 | setTerminalFocusRequestId((value) => value + 1); | 1835 | setTerminalFocusRequestId((value) => value + 1); |
| 1647 | }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); | 1836 | }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); |
| 1648 | const createNewTerminal = useCallback(() => { | 1837 | const createNewTerminal = useCallback(() => { |
| 1649 | if (!activeThreadId) return; | 1838 | if (!activeThreadRef) return; |
| 1650 | const terminalId = `terminal-${randomUUID()}`; | 1839 | const terminalId = `terminal-${randomUUID()}`; |
| 1651 | storeNewTerminal(activeThreadId, terminalId); | 1840 | storeNewTerminal(activeThreadRef, terminalId); |
| 1652 | setTerminalFocusRequestId((value) => value + 1); | 1841 | setTerminalFocusRequestId((value) => value + 1); |
| 1653 | }, [activeThreadId, storeNewTerminal]); | 1842 | }, [activeThreadRef, storeNewTerminal]); |
| 1654 | const closeTerminal = useCallback( | 1843 | const closeTerminal = useCallback( |
| 1655 | (terminalId: string) => { | 1844 | (terminalId: string) => { |
| 1656 | const api = readNativeApi(); | 1845 | const api = readEnvironmentApi(environmentId); |
| 1657 | if (!activeThreadId || !api) return; | 1846 | if (!activeThreadId || !api) return; |
| 1658 | const isFinalTerminal = terminalState.terminalIds.length <= 1; | 1847 | const isFinalTerminal = terminalState.terminalIds.length <= 1; |
| 1659 | const fallbackExitWrite = () => | 1848 | const fallbackExitWrite = () => |
| ... | @@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1676 | } else { | 1865 | } else { |
| 1677 | void fallbackExitWrite(); | 1866 | void fallbackExitWrite(); |
| 1678 | } | 1867 | } |
| 1679 | storeCloseTerminal(activeThreadId, terminalId); | 1868 | if (activeThreadRef) { |
| 1869 | storeCloseTerminal(activeThreadRef, terminalId); | ||
| 1870 | } | ||
| 1680 | setTerminalFocusRequestId((value) => value + 1); | 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 | const runProjectScript = useCallback( | 1881 | const runProjectScript = useCallback( |
| 1685 | async ( | 1882 | async ( |
| ... | @@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1692 | rememberAsLastInvoked?: boolean; | 1889 | rememberAsLastInvoked?: boolean; |
| 1693 | }, | 1890 | }, |
| 1694 | ) => { | 1891 | ) => { |
| 1695 | const api = readNativeApi(); | 1892 | const api = readEnvironmentApi(environmentId); |
| 1696 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | 1893 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 1697 | if (options?.rememberAsLastInvoked !== false) { | 1894 | if (options?.rememberAsLastInvoked !== false) { |
| 1698 | setLastInvokedScriptByProjectId((current) => { | 1895 | setLastInvokedScriptByProjectId((current) => { |
| ... | @@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1719 | worktreePath: targetWorktreePath, | 1916 | worktreePath: targetWorktreePath, |
| 1720 | }); | 1917 | }); |
| 1721 | setTerminalOpen(true); | 1918 | setTerminalOpen(true); |
| 1919 | if (!activeThreadRef) { | ||
| 1920 | return; | ||
| 1921 | } | ||
| 1722 | if (shouldCreateNewTerminal) { | 1922 | if (shouldCreateNewTerminal) { |
| 1723 | storeNewTerminal(activeThreadId, targetTerminalId); | 1923 | storeNewTerminal(activeThreadRef, targetTerminalId); |
| 1724 | } else { | 1924 | } else { |
| 1725 | storeSetActiveTerminal(activeThreadId, targetTerminalId); | 1925 | storeSetActiveTerminal(activeThreadRef, targetTerminalId); |
| 1726 | } | 1926 | } |
| 1727 | setTerminalFocusRequestId((value) => value + 1); | 1927 | setTerminalFocusRequestId((value) => value + 1); |
| 1728 | 1928 | ||
| ... | @@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1769 | activeProject, | 1969 | activeProject, |
| 1770 | activeThread, | 1970 | activeThread, |
| 1771 | activeThreadId, | 1971 | activeThreadId, |
| 1972 | activeThreadRef, | ||
| 1772 | gitCwd, | 1973 | gitCwd, |
| 1773 | setTerminalOpen, | 1974 | setTerminalOpen, |
| 1774 | setThreadError, | 1975 | setThreadError, |
| 1775 | storeNewTerminal, | 1976 | storeNewTerminal, |
| 1776 | storeSetActiveTerminal, | 1977 | storeSetActiveTerminal, |
| 1777 | setLastInvokedScriptByProjectId, | 1978 | setLastInvokedScriptByProjectId, |
| 1979 | environmentId, | ||
| 1778 | terminalState.activeTerminalId, | 1980 | terminalState.activeTerminalId, |
| 1779 | terminalState.runningTerminalIds, | 1981 | terminalState.runningTerminalIds, |
| 1780 | terminalState.terminalIds, | 1982 | terminalState.terminalIds, |
| ... | @@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1790 | keybinding?: string | null; | 1992 | keybinding?: string | null; |
| 1791 | keybindingCommand: KeybindingCommand; | 1993 | keybindingCommand: KeybindingCommand; |
| 1792 | }) => { | 1994 | }) => { |
| 1793 | const api = readNativeApi(); | 1995 | const api = readEnvironmentApi(environmentId); |
| 1794 | if (!api) return; | 1996 | if (!api) return; |
| 1795 | 1997 | ||
| 1796 | await api.orchestration.dispatchCommand({ | 1998 | await api.orchestration.dispatchCommand({ |
| ... | @@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1806 | }); | 2008 | }); |
| 1807 | 2009 | ||
| 1808 | if (isElectron && keybindingRule) { | 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 | const saveProjectScript = useCallback( | 2020 | const saveProjectScript = useCallback( |
| 1815 | async (input: NewProjectScriptInput) => { | 2021 | async (input: NewProjectScriptInput) => { |
| ... | @@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1913 | const handleRuntimeModeChange = useCallback( | 2119 | const handleRuntimeModeChange = useCallback( |
| 1914 | (mode: RuntimeMode) => { | 2120 | (mode: RuntimeMode) => { |
| 1915 | if (mode === runtimeMode) return; | 2121 | if (mode === runtimeMode) return; |
| 1916 | setComposerDraftRuntimeMode(threadId, mode); | 2122 | setComposerDraftRuntimeMode(composerDraftTarget, mode); |
| 1917 | if (isLocalDraftThread) { | 2123 | if (isLocalDraftThread) { |
| 1918 | setDraftThreadContext(threadId, { runtimeMode: mode }); | 2124 | setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); |
| 1919 | } | 2125 | } |
| 1920 | scheduleComposerFocus(); | 2126 | scheduleComposerFocus(); |
| 1921 | }, | 2127 | }, |
| ... | @@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1923 | isLocalDraftThread, | 2129 | isLocalDraftThread, |
| 1924 | runtimeMode, | 2130 | runtimeMode, |
| 1925 | scheduleComposerFocus, | 2131 | scheduleComposerFocus, |
| 2132 | composerDraftTarget, | ||
| 1926 | setComposerDraftRuntimeMode, | 2133 | setComposerDraftRuntimeMode, |
| 1927 | setDraftThreadContext, | 2134 | setDraftThreadContext, |
| 1928 | threadId, | ||
| 1929 | ], | 2135 | ], |
| 1930 | ); | 2136 | ); |
| 1931 | 2137 | ||
| 1932 | const handleInteractionModeChange = useCallback( | 2138 | const handleInteractionModeChange = useCallback( |
| 1933 | (mode: ProviderInteractionMode) => { | 2139 | (mode: ProviderInteractionMode) => { |
| 1934 | if (mode === interactionMode) return; | 2140 | if (mode === interactionMode) return; |
| 1935 | setComposerDraftInteractionMode(threadId, mode); | 2141 | setComposerDraftInteractionMode(composerDraftTarget, mode); |
| 1936 | if (isLocalDraftThread) { | 2142 | if (isLocalDraftThread) { |
| 1937 | setDraftThreadContext(threadId, { interactionMode: mode }); | 2143 | setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); |
| 1938 | } | 2144 | } |
| 1939 | scheduleComposerFocus(); | 2145 | scheduleComposerFocus(); |
| 1940 | }, | 2146 | }, |
| ... | @@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1942 | interactionMode, | 2148 | interactionMode, |
| 1943 | isLocalDraftThread, | 2149 | isLocalDraftThread, |
| 1944 | scheduleComposerFocus, | 2150 | scheduleComposerFocus, |
| 2151 | composerDraftTarget, | ||
| 1945 | setComposerDraftInteractionMode, | 2152 | setComposerDraftInteractionMode, |
| 1946 | setDraftThreadContext, | 2153 | setDraftThreadContext, |
| 1947 | threadId, | ||
| 1948 | ], | 2154 | ], |
| 1949 | ); | 2155 | ); |
| 1950 | const toggleInteractionMode = useCallback(() => { | 2156 | const toggleInteractionMode = useCallback(() => { |
| ... | @@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1980 | if (!serverThread) { | 2186 | if (!serverThread) { |
| 1981 | return; | 2187 | return; |
| 1982 | } | 2188 | } |
| 1983 | const api = readNativeApi(); | 2189 | const api = readEnvironmentApi(environmentId); |
| 1984 | if (!api) { | 2190 | if (!api) { |
| 1985 | return; | 2191 | return; |
| 1986 | } | 2192 | } |
| ... | @@ -2020,7 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -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 | // Auto-scroll on new messages | 2232 | // Auto-scroll on new messages |
| ... | @@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2350 | dragDepthRef.current = 0; | 2556 | dragDepthRef.current = 0; |
| 2351 | setIsDragOverComposer(false); | 2557 | setIsDragOverComposer(false); |
| 2352 | setExpandedImage(null); | 2558 | setExpandedImage(null); |
| 2353 | }, [resetLocalDispatch, threadId]); | 2559 | }, [draftId, resetLocalDispatch, threadId]); |
| 2354 | 2560 | ||
| 2355 | useEffect(() => { | 2561 | useEffect(() => { |
| 2356 | let cancelled = false; | 2562 | let cancelled = false; |
| 2357 | void (async () => { | 2563 | void (async () => { |
| 2358 | if (composerImages.length === 0) { | 2564 | if (composerImages.length === 0) { |
| 2359 | clearComposerDraftPersistedAttachments(threadId); | 2565 | clearComposerDraftPersistedAttachments(composerDraftTarget); |
| 2360 | return; | 2566 | return; |
| 2361 | } | 2567 | } |
| 2362 | const getPersistedAttachmentsForThread = () => | 2568 | const getPersistedAttachmentsForThread = () => |
| 2363 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; | 2569 | getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; |
| 2364 | try { | 2570 | try { |
| 2365 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); | 2571 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); |
| 2366 | const existingPersistedById = new Map( | 2572 | const existingPersistedById = new Map( |
| ... | @@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2391 | return; | 2597 | return; |
| 2392 | } | 2598 | } |
| 2393 | // Stage attachments in persisted draft state first so persist middleware can write them. | 2599 | // Stage attachments in persisted draft state first so persist middleware can write them. |
| 2394 | syncComposerDraftPersistedAttachments(threadId, serialized); | 2600 | syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); |
| 2395 | } catch { | 2601 | } catch { |
| 2396 | const currentImageIds = new Set(composerImages.map((image) => image.id)); | 2602 | const currentImageIds = new Set(composerImages.map((image) => image.id)); |
| 2397 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); | 2603 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); |
| ... | @@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2405 | if (cancelled) { | 2611 | if (cancelled) { |
| 2406 | return; | 2612 | return; |
| 2407 | } | 2613 | } |
| 2408 | syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); | 2614 | syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); |
| 2409 | } | 2615 | } |
| 2410 | })(); | 2616 | })(); |
| 2411 | return () => { | 2617 | return () => { |
| 2412 | cancelled = true; | 2618 | cancelled = true; |
| 2413 | }; | 2619 | }; |
| 2414 | }, [ | 2620 | }, [ |
| 2621 | composerDraftTarget, | ||
| 2415 | clearComposerDraftPersistedAttachments, | 2622 | clearComposerDraftPersistedAttachments, |
| 2416 | composerImages, | 2623 | composerImages, |
| 2624 | getComposerDraft, | ||
| 2417 | syncComposerDraftPersistedAttachments, | 2625 | syncComposerDraftPersistedAttachments, |
| 2418 | threadId, | ||
| 2419 | ]); | 2626 | ]); |
| 2420 | 2627 | ||
| 2421 | const closeExpandedImage = useCallback(() => { | 2628 | const closeExpandedImage = useCallback(() => { |
| ... | @@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2476 | useEffect(() => { | 2683 | useEffect(() => { |
| 2477 | if (!activeThreadId) { | 2684 | if (!activeThreadId) { |
| 2478 | setTerminalLaunchContext(null); | 2685 | setTerminalLaunchContext(null); |
| 2479 | storeClearTerminalLaunchContext(threadId); | 2686 | storeClearTerminalLaunchContext(routeThreadRef); |
| 2480 | return; | 2687 | return; |
| 2481 | } | 2688 | } |
| 2482 | setTerminalLaunchContext((current) => { | 2689 | setTerminalLaunchContext((current) => { |
| ... | @@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2484 | if (current.threadId === activeThreadId) return current; | 2691 | if (current.threadId === activeThreadId) return current; |
| 2485 | return null; | 2692 | return null; |
| 2486 | }); | 2693 | }); |
| 2487 | }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); | 2694 | }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); |
| 2488 | 2695 | ||
| 2489 | useEffect(() => { | 2696 | useEffect(() => { |
| 2490 | if (!activeThreadId || !activeProjectCwd) { | 2697 | if (!activeThreadId || !activeProjectCwd) { |
| ... | @@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2502 | settledCwd === current.cwd && | 2709 | settledCwd === current.cwd && |
| 2503 | (activeThreadWorktreePath ?? null) === current.worktreePath | 2710 | (activeThreadWorktreePath ?? null) === current.worktreePath |
| 2504 | ) { | 2711 | ) { |
| 2505 | storeClearTerminalLaunchContext(activeThreadId); | 2712 | if (activeThreadRef) { |
| 2713 | storeClearTerminalLaunchContext(activeThreadRef); | ||
| 2714 | } | ||
| 2506 | return null; | 2715 | return null; |
| 2507 | } | 2716 | } |
| 2508 | return current; | 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 | useEffect(() => { | 2727 | useEffect(() => { |
| 2513 | if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { | 2728 | if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { |
| ... | @@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2521 | settledCwd === storeServerTerminalLaunchContext.cwd && | 2736 | settledCwd === storeServerTerminalLaunchContext.cwd && |
| 2522 | (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath | 2737 | (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath |
| 2523 | ) { | 2738 | ) { |
| 2524 | storeClearTerminalLaunchContext(activeThreadId); | 2739 | if (activeThreadRef) { |
| 2740 | storeClearTerminalLaunchContext(activeThreadRef); | ||
| 2741 | } | ||
| 2525 | } | 2742 | } |
| 2526 | }, [ | 2743 | }, [ |
| 2527 | activeProjectCwd, | 2744 | activeProjectCwd, |
| 2528 | activeThreadId, | 2745 | activeThreadId, |
| 2746 | activeThreadRef, | ||
| 2529 | activeThreadWorktreePath, | 2747 | activeThreadWorktreePath, |
| 2530 | storeClearTerminalLaunchContext, | 2748 | storeClearTerminalLaunchContext, |
| 2531 | storeServerTerminalLaunchContext, | 2749 | storeServerTerminalLaunchContext, |
| ... | @@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2535 | if (terminalState.terminalOpen) { | 2753 | if (terminalState.terminalOpen) { |
| 2536 | return; | 2754 | return; |
| 2537 | } | 2755 | } |
| 2538 | if (activeThreadId) { | 2756 | if (activeThreadRef) { |
| 2539 | storeClearTerminalLaunchContext(activeThreadId); | 2757 | storeClearTerminalLaunchContext(activeThreadRef); |
| 2540 | } | 2758 | } |
| 2541 | setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); | 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 | useEffect(() => { | 2767 | useEffect(() => { |
| 2545 | if (phase !== "running") return; | 2768 | if (phase !== "running") return; |
| ... | @@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2552 | }, [phase]); | 2775 | }, [phase]); |
| 2553 | 2776 | ||
| 2554 | useEffect(() => { | 2777 | useEffect(() => { |
| 2555 | if (!activeThreadId) return; | 2778 | if (!activeThreadKey) return; |
| 2556 | const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; | 2779 | const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; |
| 2557 | const current = Boolean(terminalState.terminalOpen); | 2780 | const current = Boolean(terminalState.terminalOpen); |
| 2558 | 2781 | ||
| 2559 | if (!previous && current) { | 2782 | if (!previous && current) { |
| 2560 | terminalOpenByThreadRef.current[activeThreadId] = current; | 2783 | terminalOpenByThreadRef.current[activeThreadKey] = current; |
| 2561 | setTerminalFocusRequestId((value) => value + 1); | 2784 | setTerminalFocusRequestId((value) => value + 1); |
| 2562 | return; | 2785 | return; |
| 2563 | } else if (previous && !current) { | 2786 | } else if (previous && !current) { |
| 2564 | terminalOpenByThreadRef.current[activeThreadId] = current; | 2787 | terminalOpenByThreadRef.current[activeThreadKey] = current; |
| 2565 | const frame = window.requestAnimationFrame(() => { | 2788 | const frame = window.requestAnimationFrame(() => { |
| 2566 | focusComposer(); | 2789 | focusComposer(); |
| 2567 | }); | 2790 | }); |
| ... | @@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2570 | }; | 2793 | }; |
| 2571 | } | 2794 | } |
| 2572 | 2795 | ||
| 2573 | terminalOpenByThreadRef.current[activeThreadId] = current; | 2796 | terminalOpenByThreadRef.current[activeThreadKey] = current; |
| 2574 | }, [activeThreadId, focusComposer, terminalState.terminalOpen]); | 2797 | }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); |
| 2575 | 2798 | ||
| 2576 | useEffect(() => { | 2799 | useEffect(() => { |
| 2577 | const handler = (event: globalThis.KeyboardEvent) => { | 2800 | const handler = (event: globalThis.KeyboardEvent) => { |
| ... | @@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2766 | 2989 | ||
| 2767 | const onRevertToTurnCount = useCallback( | 2990 | const onRevertToTurnCount = useCallback( |
| 2768 | async (turnCount: number) => { | 2991 | async (turnCount: number) => { |
| 2769 | const api = readNativeApi(); | 2992 | const api = readEnvironmentApi(environmentId); |
| 2770 | if (!api || !activeThread || isRevertingCheckpoint) return; | 2993 | const localApi = readLocalApi(); |
| 2994 | if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; | ||
| 2771 | 2995 | ||
| 2772 | if (phase === "running" || isSendBusy || isConnecting) { | 2996 | if (phase === "running" || isSendBusy || isConnecting) { |
| 2773 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); | 2997 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 2774 | return; | 2998 | return; |
| 2775 | } | 2999 | } |
| 2776 | const confirmed = await api.dialogs.confirm( | 3000 | const confirmed = await localApi.dialogs.confirm( |
| 2777 | [ | 3001 | [ |
| 2778 | `Revert this thread to checkpoint ${turnCount}?`, | 3002 | `Revert this thread to checkpoint ${turnCount}?`, |
| 2779 | "This will discard newer messages and turn diffs in this thread.", | 3003 | "This will discard newer messages and turn diffs in this thread.", |
| ... | @@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2802 | } | 3026 | } |
| 2803 | setIsRevertingCheckpoint(false); | 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 | const onSend = async (e?: { preventDefault: () => void }) => { | 3040 | const onSend = async (e?: { preventDefault: () => void }) => { |
| 2809 | e?.preventDefault(); | 3041 | e?.preventDefault(); |
| 2810 | const api = readNativeApi(); | 3042 | const api = readEnvironmentApi(environmentId); |
| 2811 | if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; | 3043 | if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; |
| 2812 | if (activePendingProgress) { | 3044 | if (activePendingProgress) { |
| 2813 | onAdvanceActivePendingUserInput(); | 3045 | onAdvanceActivePendingUserInput(); |
| ... | @@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2830 | planMarkdown: activeProposedPlan.planMarkdown, | 3062 | planMarkdown: activeProposedPlan.planMarkdown, |
| 2831 | }); | 3063 | }); |
| 2832 | promptRef.current = ""; | 3064 | promptRef.current = ""; |
| 2833 | clearComposerDraftContent(activeThread.id); | 3065 | clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); |
| 2834 | setComposerHighlightedItemId(null); | 3066 | setComposerHighlightedItemId(null); |
| 2835 | setComposerCursor(0); | 3067 | setComposerCursor(0); |
| 2836 | setComposerTrigger(null); | 3068 | setComposerTrigger(null); |
| ... | @@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2847 | if (standaloneSlashCommand) { | 3079 | if (standaloneSlashCommand) { |
| 2848 | handleInteractionModeChange(standaloneSlashCommand); | 3080 | handleInteractionModeChange(standaloneSlashCommand); |
| 2849 | promptRef.current = ""; | 3081 | promptRef.current = ""; |
| 2850 | clearComposerDraftContent(activeThread.id); | 3082 | clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); |
| 2851 | setComposerHighlightedItemId(null); | 3083 | setComposerHighlightedItemId(null); |
| 2852 | setComposerCursor(0); | 3084 | setComposerCursor(0); |
| 2853 | setComposerTrigger(null); | 3085 | setComposerTrigger(null); |
| ... | @@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2880 | const shouldCreateWorktree = | 3112 | const shouldCreateWorktree = |
| 2881 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; | 3113 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; |
| 2882 | if (shouldCreateWorktree && !activeThread.branch) { | 3114 | if (shouldCreateWorktree && !activeThread.branch) { |
| 2883 | setStoreThreadError( | 3115 | setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); |
| 2884 | threadIdForSend, | ||
| 2885 | "Select a base branch before sending in New worktree mode.", | ||
| 2886 | ); | ||
| 2887 | return; | 3116 | return; |
| 2888 | } | 3117 | } |
| 2889 | 3118 | ||
| ... | @@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2950 | }); | 3179 | }); |
| 2951 | } | 3180 | } |
| 2952 | promptRef.current = ""; | 3181 | promptRef.current = ""; |
| 2953 | clearComposerDraftContent(threadIdForSend); | 3182 | clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); |
| 2954 | setComposerHighlightedItemId(null); | 3183 | setComposerHighlightedItemId(null); |
| 2955 | setComposerCursor(0); | 3184 | setComposerCursor(0); |
| 2956 | setComposerTrigger(null); | 3185 | setComposerTrigger(null); |
| ... | @@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3087 | }; | 3316 | }; |
| 3088 | 3317 | ||
| 3089 | const onInterrupt = async () => { | 3318 | const onInterrupt = async () => { |
| 3090 | const api = readNativeApi(); | 3319 | const api = readEnvironmentApi(environmentId); |
| 3091 | if (!api || !activeThread) return; | 3320 | if (!api || !activeThread) return; |
| 3092 | await api.orchestration.dispatchCommand({ | 3321 | await api.orchestration.dispatchCommand({ |
| 3093 | type: "thread.turn.interrupt", | 3322 | type: "thread.turn.interrupt", |
| ... | @@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3099 | 3328 | ||
| 3100 | const onRespondToApproval = useCallback( | 3329 | const onRespondToApproval = useCallback( |
| 3101 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | 3330 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { |
| 3102 | const api = readNativeApi(); | 3331 | const api = readEnvironmentApi(environmentId); |
| 3103 | if (!api || !activeThreadId) return; | 3332 | if (!api || !activeThreadId) return; |
| 3104 | 3333 | ||
| 3105 | setRespondingRequestIds((existing) => | 3334 | setRespondingRequestIds((existing) => |
| ... | @@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3122 | }); | 3351 | }); |
| 3123 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | 3352 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 3124 | }, | 3353 | }, |
| 3125 | [activeThreadId, setThreadError], | 3354 | [activeThreadId, environmentId, setThreadError], |
| 3126 | ); | 3355 | ); |
| 3127 | 3356 | ||
| 3128 | const onRespondToUserInput = useCallback( | 3357 | const onRespondToUserInput = useCallback( |
| 3129 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { | 3358 | async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => { |
| 3130 | const api = readNativeApi(); | 3359 | const api = readEnvironmentApi(environmentId); |
| 3131 | if (!api || !activeThreadId) return; | 3360 | if (!api || !activeThreadId) return; |
| 3132 | 3361 | ||
| 3133 | setRespondingUserInputRequestIds((existing) => | 3362 | setRespondingUserInputRequestIds((existing) => |
| ... | @@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3150 | }); | 3379 | }); |
| 3151 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); | 3380 | setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 3152 | }, | 3381 | }, |
| 3153 | [activeThreadId, setThreadError], | 3382 | [activeThreadId, environmentId, setThreadError], |
| 3154 | ); | 3383 | ); |
| 3155 | 3384 | ||
| 3156 | const setActivePendingUserInputQuestionIndex = useCallback( | 3385 | const setActivePendingUserInputQuestionIndex = useCallback( |
| ... | @@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3166 | [activePendingUserInput], | 3395 | [activePendingUserInput], |
| 3167 | ); | 3396 | ); |
| 3168 | 3397 | ||
| 3169 | const onToggleActivePendingUserInputOption = useCallback( | 3398 | const onSelectActivePendingUserInputOption = useCallback( |
| 3170 | (questionId: string, optionLabel: string) => { | 3399 | (questionId: string, optionLabel: string) => { |
| 3171 | if (!activePendingUserInput) { | 3400 | if (!activePendingUserInput) { |
| 3172 | return; | 3401 | return; |
| 3173 | } | 3402 | } |
| 3174 | const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); | 3403 | setPendingUserInputAnswersByRequestId((existing) => { |
| 3175 | if (!question) { | 3404 | const question = |
| 3176 | return; | 3405 | (activePendingProgress?.activeQuestion?.id === questionId |
| 3177 | } | 3406 | ? activePendingProgress.activeQuestion |
| 3178 | setPendingUserInputAnswersByRequestId((existing) => ({ | 3407 | : undefined) ?? |
| 3179 | ...existing, | 3408 | activePendingUserInput.questions.find((entry) => entry.id === questionId); |
| 3180 | [activePendingUserInput.requestId]: { | 3409 | if (!question) { |
| 3181 | ...existing[activePendingUserInput.requestId], | 3410 | return existing; |
| 3182 | [questionId]: togglePendingUserInputOptionSelection( | 3411 | } |
| 3183 | question, | 3412 | |
| 3184 | existing[activePendingUserInput.requestId]?.[questionId], | 3413 | return { |
| 3185 | optionLabel, | 3414 | ...existing, |
| 3186 | ), | 3415 | [activePendingUserInput.requestId]: { |
| 3187 | }, | 3416 | ...existing[activePendingUserInput.requestId], |
| 3188 | })); | 3417 | [questionId]: togglePendingUserInputOptionSelection( |
| 3418 | question, | ||
| 3419 | existing[activePendingUserInput.requestId]?.[questionId], | ||
| 3420 | optionLabel, | ||
| 3421 | ), | ||
| 3422 | }, | ||
| 3423 | }; | ||
| 3424 | }); | ||
| 3189 | promptRef.current = ""; | 3425 | promptRef.current = ""; |
| 3190 | setComposerCursor(0); | 3426 | setComposerCursor(0); |
| 3191 | setComposerTrigger(null); | 3427 | setComposerTrigger(null); |
| 3192 | }, | 3428 | }, |
| 3193 | [activePendingUserInput], | 3429 | [activePendingProgress?.activeQuestion, activePendingUserInput], |
| 3194 | ); | 3430 | ); |
| 3195 | 3431 | ||
| 3196 | const onChangeActivePendingUserInputCustomAnswer = useCallback( | 3432 | const onChangeActivePendingUserInputCustomAnswer = useCallback( |
| ... | @@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3257 | text: string; | 3493 | text: string; |
| 3258 | interactionMode: "default" | "plan"; | 3494 | interactionMode: "default" | "plan"; |
| 3259 | }) => { | 3495 | }) => { |
| 3260 | const api = readNativeApi(); | 3496 | const api = readEnvironmentApi(environmentId); |
| 3261 | if ( | 3497 | if ( |
| 3262 | !api || | 3498 | !api || |
| 3263 | !activeThread || | 3499 | !activeThread || |
| ... | @@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3312 | 3548 | ||
| 3313 | // Keep the mode toggle and plan-follow-up banner in sync immediately | 3549 | // Keep the mode toggle and plan-follow-up banner in sync immediately |
| 3314 | // while the same-thread implementation turn is starting. | 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 | await api.orchestration.dispatchCommand({ | 3556 | await api.orchestration.dispatchCommand({ |
| 3318 | type: "thread.turn.start", | 3557 | type: "thread.turn.start", |
| ... | @@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3376 | setComposerDraftInteractionMode, | 3615 | setComposerDraftInteractionMode, |
| 3377 | setThreadError, | 3616 | setThreadError, |
| 3378 | selectedModel, | 3617 | selectedModel, |
| 3618 | environmentId, | ||
| 3379 | ], | 3619 | ], |
| 3380 | ); | 3620 | ); |
| 3381 | 3621 | ||
| 3382 | const onImplementPlanInNewThread = useCallback(async () => { | 3622 | const onImplementPlanInNewThread = useCallback(async () => { |
| 3383 | const api = readNativeApi(); | 3623 | const api = readEnvironmentApi(environmentId); |
| 3384 | if ( | 3624 | if ( |
| 3385 | !api || | 3625 | !api || |
| 3386 | !activeThread || | 3626 | !activeThread || |
| ... | @@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3452 | }); | 3692 | }); |
| 3453 | }) | 3693 | }) |
| 3454 | .then(() => { | 3694 | .then(() => { |
| 3455 | return waitForStartedServerThread(nextThreadId); | 3695 | return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); |
| 3456 | }) | 3696 | }) |
| 3457 | .then(() => { | 3697 | .then(() => { |
| 3458 | // Signal that the plan sidebar should open on the new thread. | 3698 | // Signal that the plan sidebar should open on the new thread. |
| 3459 | planSidebarOpenOnNextThreadRef.current = true; | 3699 | planSidebarOpenOnNextThreadRef.current = true; |
| 3460 | return navigate({ | 3700 | return navigate({ |
| 3461 | to: "/$threadId", | 3701 | to: "/$environmentId/$threadId", |
| 3462 | params: { threadId: nextThreadId }, | 3702 | params: { |
| 3703 | environmentId: activeThread.environmentId, | ||
| 3704 | threadId: nextThreadId, | ||
| 3705 | }, | ||
| 3463 | }); | 3706 | }); |
| 3464 | }) | 3707 | }) |
| 3465 | .catch(async (err) => { | 3708 | .catch(async (err: unknown) => { |
| 3466 | await api.orchestration | 3709 | await api.orchestration |
| 3467 | .dispatchCommand({ | 3710 | .dispatchCommand({ |
| 3468 | type: "thread.delete", | 3711 | type: "thread.delete", |
| ... | @@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3494 | selectedProvider, | 3737 | selectedProvider, |
| 3495 | selectedProviderModels, | 3738 | selectedProviderModels, |
| 3496 | selectedModel, | 3739 | selectedModel, |
| 3740 | environmentId, | ||
| 3497 | ]); | 3741 | ]); |
| 3498 | 3742 | ||
| 3499 | const onProviderModelSelect = useCallback( | 3743 | const onProviderModelSelect = useCallback( |
| ... | @@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3514 | provider: resolvedProvider, | 3758 | provider: resolvedProvider, |
| 3515 | model: resolvedModel, | 3759 | model: resolvedModel, |
| 3516 | }; | 3760 | }; |
| 3517 | setComposerDraftModelSelection(activeThread.id, nextModelSelection); | 3761 | setComposerDraftModelSelection( |
| 3762 | scopeThreadRef(activeThread.environmentId, activeThread.id), | ||
| 3763 | nextModelSelection, | ||
| 3764 | ); | ||
| 3518 | setStickyComposerModelSelection(nextModelSelection); | 3765 | setStickyComposerModelSelection(nextModelSelection); |
| 3519 | scheduleComposerFocus(); | 3766 | scheduleComposerFocus(); |
| 3520 | }, | 3767 | }, |
| ... | @@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3546 | ); | 3793 | ); |
| 3547 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ | 3794 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ |
| 3548 | provider: selectedProvider, | 3795 | provider: selectedProvider, |
| 3549 | threadId, | 3796 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), |
| 3797 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | ||
| 3550 | model: selectedModel, | 3798 | model: selectedModel, |
| 3551 | models: selectedProviderModels, | 3799 | models: selectedProviderModels, |
| 3552 | modelOptions: composerModelOptions?.[selectedProvider], | 3800 | modelOptions: composerModelOptions?.[selectedProvider], |
| ... | @@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3555 | }); | 3803 | }); |
| 3556 | const providerTraitsPicker = renderProviderTraitsPicker({ | 3804 | const providerTraitsPicker = renderProviderTraitsPicker({ |
| 3557 | provider: selectedProvider, | 3805 | provider: selectedProvider, |
| 3558 | threadId, | 3806 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), |
| 3807 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | ||
| 3559 | model: selectedModel, | 3808 | model: selectedModel, |
| 3560 | models: selectedProviderModels, | 3809 | models: selectedProviderModels, |
| 3561 | modelOptions: composerModelOptions?.[selectedProvider], | 3810 | modelOptions: composerModelOptions?.[selectedProvider], |
| ... | @@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3565 | const onEnvModeChange = useCallback( | 3814 | const onEnvModeChange = useCallback( |
| 3566 | (mode: DraftThreadEnvMode) => { | 3815 | (mode: DraftThreadEnvMode) => { |
| 3567 | if (isLocalDraftThread) { | 3816 | if (isLocalDraftThread) { |
| 3568 | setDraftThreadContext(threadId, { envMode: mode }); | 3817 | setDraftThreadContext(composerDraftTarget, { envMode: mode }); |
| 3569 | } | 3818 | } |
| 3570 | scheduleComposerFocus(); | 3819 | scheduleComposerFocus(); |
| 3571 | }, | 3820 | }, |
| 3572 | [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], | 3821 | [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext], |
| 3573 | ); | 3822 | ); |
| 3574 | 3823 | ||
| 3575 | const applyPromptReplacement = useCallback( | 3824 | const applyPromptReplacement = useCallback( |
| ... | @@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3766 | setPrompt(nextPrompt); | 4015 | setPrompt(nextPrompt); |
| 3767 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { | 4016 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { |
| 3768 | setComposerDraftTerminalContexts( | 4017 | setComposerDraftTerminalContexts( |
| 3769 | threadId, | 4018 | composerDraftTarget, |
| 3770 | syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), | 4019 | syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), |
| 3771 | ); | 4020 | ); |
| 3772 | } | 4021 | } |
| ... | @@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3781 | composerTerminalContexts, | 4030 | composerTerminalContexts, |
| 3782 | onChangeActivePendingUserInputCustomAnswer, | 4031 | onChangeActivePendingUserInputCustomAnswer, |
| 3783 | setPrompt, | 4032 | setPrompt, |
| 4033 | composerDraftTarget, | ||
| 3784 | setComposerDraftTerminalContexts, | 4034 | setComposerDraftTerminalContexts, |
| 3785 | threadId, | ||
| 3786 | ], | 4035 | ], |
| 3787 | ); | 4036 | ); |
| 3788 | 4037 | ||
| ... | @@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3835 | const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; | 4084 | const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; |
| 3836 | const onOpenTurnDiff = useCallback( | 4085 | const onOpenTurnDiff = useCallback( |
| 3837 | (turnId: TurnId, filePath?: string) => { | 4086 | (turnId: TurnId, filePath?: string) => { |
| 4087 | if (!isServerThread) { | ||
| 4088 | return; | ||
| 4089 | } | ||
| 3838 | void navigate({ | 4090 | void navigate({ |
| 3839 | to: "/$threadId", | 4091 | to: "/$environmentId/$threadId", |
| 3840 | params: { threadId }, | 4092 | params: { |
| 4093 | environmentId, | ||
| 4094 | threadId, | ||
| 4095 | }, | ||
| 3841 | search: (previous) => { | 4096 | search: (previous) => { |
| 3842 | const rest = stripDiffSearchParams(previous); | 4097 | const rest = stripDiffSearchParams(previous); |
| 3843 | return filePath | 4098 | return filePath |
| ... | @@ -3846,7 +4101,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -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 | const onRevertUserMessage = (messageId: MessageId) => { | 4106 | const onRevertUserMessage = (messageId: MessageId) => { |
| 3852 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); | 4107 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); |
| ... | @@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3892 | )} | 4147 | )} |
| 3893 | > | 4148 | > |
| 3894 | <ChatHeader | 4149 | <ChatHeader |
| 4150 | activeThreadEnvironmentId={activeThread.environmentId} | ||
| 3895 | activeThreadId={activeThread.id} | 4151 | activeThreadId={activeThread.id} |
| 3896 | activeThreadTitle={activeThread.title} | 4152 | activeThreadTitle={activeThread.title} |
| 3897 | activeProjectName={activeProject?.name} | 4153 | activeProjectName={activeProject?.name} |
| ... | @@ -3959,6 +4215,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3959,6 +4215,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3959 | completionSummary={completionSummary} | 4215 | completionSummary={completionSummary} |
| 3960 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} | 4216 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 3961 | nowIso={nowIso} | 4217 | nowIso={nowIso} |
| 4218 | activeThreadEnvironmentId={activeThread.environmentId} | ||
| 3962 | expandedWorkGroups={expandedWorkGroups} | 4219 | expandedWorkGroups={expandedWorkGroups} |
| 3963 | onToggleWorkGroup={onToggleWorkGroup} | 4220 | onToggleWorkGroup={onToggleWorkGroup} |
| 3964 | onOpenTurnDiff={onOpenTurnDiff} | 4221 | onOpenTurnDiff={onOpenTurnDiff} |
| ... | @@ -4027,7 +4284,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -4027,7 +4284,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4027 | respondingRequestIds={respondingRequestIds} | 4284 | respondingRequestIds={respondingRequestIds} |
| 4028 | answers={activePendingDraftAnswers} | 4285 | answers={activePendingDraftAnswers} |
| 4029 | questionIndex={activePendingQuestionIndex} | 4286 | questionIndex={activePendingQuestionIndex} |
| 4030 | onToggleOption={onToggleActivePendingUserInputOption} | 4287 | onToggleOption={onSelectActivePendingUserInputOption} |
| 4031 | onAdvance={onAdvanceActivePendingUserInput} | 4288 | onAdvance={onAdvanceActivePendingUserInput} |
| 4032 | /> | 4289 | /> |
| 4033 | </div> | 4290 | </div> |
| ... | @@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4370 | 4627 | ||
| 4371 | {isGitRepo && ( | 4628 | {isGitRepo && ( |
| 4372 | <BranchToolbar | 4629 | <BranchToolbar |
| 4630 | environmentId={activeThread.environmentId} | ||
| 4373 | threadId={activeThread.id} | 4631 | threadId={activeThread.id} |
| 4632 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | ||
| 4374 | onEnvModeChange={onEnvModeChange} | 4633 | onEnvModeChange={onEnvModeChange} |
| 4375 | envLocked={envLocked} | 4634 | envLocked={envLocked} |
| 4376 | onComposerFocusRequest={scheduleComposerFocus} | 4635 | onComposerFocusRequest={scheduleComposerFocus} |
| ... | @@ -4383,6 +4642,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -4383,6 +4642,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4383 | <PullRequestThreadDialog | 4642 | <PullRequestThreadDialog |
| 4384 | key={pullRequestDialogState.key} | 4643 | key={pullRequestDialogState.key} |
| 4385 | open | 4644 | open |
| 4645 | environmentId={activeThread.environmentId} | ||
| 4386 | threadId={activeThread.id} | 4646 | threadId={activeThread.id} |
| 4387 | cwd={activeProject?.cwd ?? null} | 4647 | cwd={activeProject?.cwd ?? null} |
| 4388 | initialReference={pullRequestDialogState.initialReference} | 4648 | initialReference={pullRequestDialogState.initialReference} |
| ... | @@ -4402,6 +4662,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -4402,6 +4662,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4402 | <PlanSidebar | 4662 | <PlanSidebar |
| 4403 | activePlan={activePlan} | 4663 | activePlan={activePlan} |
| 4404 | activeProposedPlan={sidebarProposedPlan} | 4664 | activeProposedPlan={sidebarProposedPlan} |
| 4665 | environmentId={environmentId} | ||
| 4405 | markdownCwd={gitCwd ?? undefined} | 4666 | markdownCwd={gitCwd ?? undefined} |
| 4406 | workspaceRoot={activeWorkspaceRoot} | 4667 | workspaceRoot={activeWorkspaceRoot} |
| 4407 | timestampFormat={timestampFormat} | 4668 | timestampFormat={timestampFormat} |
| ... | @@ -4418,21 +4679,28 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -4418,21 +4679,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4418 | </div> | 4679 | </div> |
| 4419 | {/* end horizontal flex container */} | 4680 | {/* end horizontal flex container */} |
| 4420 | 4681 | ||
| 4421 | {mountedTerminalThreadIds.map((mountedThreadId) => ( | 4682 | {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { |
| 4422 | <PersistentThreadTerminalDrawer | 4683 | const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); |
| 4423 | key={mountedThreadId} | 4684 | if (!mountedThreadRef) { |
| 4424 | threadId={mountedThreadId} | 4685 | return []; |
| 4425 | visible={mountedThreadId === activeThreadId && terminalState.terminalOpen} | 4686 | } |
| 4426 | launchContext={ | 4687 | return [ |
| 4427 | mountedThreadId === activeThreadId ? (activeTerminalLaunchContext ?? null) : null | 4688 | <PersistentThreadTerminalDrawer |
| 4428 | } | 4689 | key={mountedThreadKey} |
| 4429 | focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0} | 4690 | threadRef={mountedThreadRef} |
| 4430 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | 4691 | threadId={mountedThreadRef.threadId} |
| 4431 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | 4692 | visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen} |
| 4432 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | 4693 | launchContext={ |
| 4433 | onAddTerminalContext={addTerminalContextToDraft} | 4694 | mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null |
| 4434 | /> | 4695 | } |
| 4435 | ))} | 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 | {expandedImage && expandedImageItem && ( | 4705 | {expandedImage && expandedImageItem && ( |
| 4438 | <div | 4706 | <div |
apps/web/src/components/DiffPanel.tsx+26-15| ... | @@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; | ... | @@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; | 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | import { useQuery } from "@tanstack/react-query"; | 3 | import { useQuery } from "@tanstack/react-query"; |
| 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | 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 | import { | 7 | import { |
| 7 | ChevronLeftIcon, | 8 | ChevronLeftIcon, |
| 8 | ChevronRightIcon, | 9 | ChevronRightIcon, |
| ... | @@ -22,15 +23,16 @@ import { openInPreferredEditor } from "../editorPreferences"; | ... | @@ -22,15 +23,16 @@ import { openInPreferredEditor } from "../editorPreferences"; |
| 22 | import { useGitStatus } from "~/lib/gitStatusState"; | 23 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 23 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 24 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 24 | import { cn } from "~/lib/utils"; | 25 | import { cn } from "~/lib/utils"; |
| 25 | import { readNativeApi } from "../nativeApi"; | 26 | import { readLocalApi } from "../localApi"; |
| 26 | import { resolvePathLinkTarget } from "../terminal-links"; | 27 | import { resolvePathLinkTarget } from "../terminal-links"; |
| 27 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 28 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 28 | import { useTheme } from "../hooks/useTheme"; | 29 | import { useTheme } from "../hooks/useTheme"; |
| 29 | import { buildPatchCacheKey } from "../lib/diffRendering"; | 30 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 30 | import { resolveDiffThemeName } from "../lib/diffRendering"; | 31 | import { resolveDiffThemeName } from "../lib/diffRendering"; |
| 31 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 32 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 32 | import { useStore } from "../store"; | 33 | import { selectProjectByRef, useStore } from "../store"; |
| 33 | import { createThreadSelector } from "../storeSelectors"; | 34 | import { createThreadSelectorByRef } from "../storeSelectors"; |
| 35 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | ||
| 34 | import { useSettings } from "../hooks/useSettings"; | 36 | import { useSettings } from "../hooks/useSettings"; |
| 35 | import { formatShortTimestamp } from "../timestampFormat"; | 37 | import { formatShortTimestamp } from "../timestampFormat"; |
| 36 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; | 38 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| ... | @@ -175,22 +177,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -175,22 +177,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 175 | const previousDiffOpenRef = useRef(false); | 177 | const previousDiffOpenRef = useRef(false); |
| 176 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); | 178 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); |
| 177 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); | 179 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); |
| 178 | const routeThreadId = useParams({ | 180 | const routeThreadRef = useParams({ |
| 179 | strict: false, | 181 | strict: false, |
| 180 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | 182 | select: (params) => resolveThreadRouteRef(params), |
| 181 | }); | 183 | }); |
| 182 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); | 184 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); |
| 183 | const diffOpen = diffSearch.diff === "1"; | 185 | const diffOpen = diffSearch.diff === "1"; |
| 184 | const activeThreadId = routeThreadId; | 186 | const activeThreadId = routeThreadRef?.threadId ?? null; |
| 185 | const activeThread = useStore( | 187 | const activeThread = useStore( |
| 186 | useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), | 188 | useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), |
| 187 | ); | 189 | ); |
| 188 | const activeProjectId = activeThread?.projectId ?? null; | 190 | const activeProjectId = activeThread?.projectId ?? null; |
| 189 | const activeProject = useStore((store) => | 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 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; | 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 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | 204 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 195 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 205 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 196 | useTurnDiffSummaries(activeThread); | 206 | useTurnDiffSummaries(activeThread); |
| ... | @@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 263 | }, [orderedTurnDiffSummaries, selectedTurn]); | 273 | }, [orderedTurnDiffSummaries, selectedTurn]); |
| 264 | const activeCheckpointDiffQuery = useQuery( | 274 | const activeCheckpointDiffQuery = useQuery( |
| 265 | checkpointDiffQueryOptions({ | 275 | checkpointDiffQueryOptions({ |
| 276 | environmentId: activeThread?.environmentId ?? null, | ||
| 266 | threadId: activeThreadId, | 277 | threadId: activeThreadId, |
| 267 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, | 278 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 268 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, | 279 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| ... | @@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 322 | 333 | ||
| 323 | const openDiffFileInEditor = useCallback( | 334 | const openDiffFileInEditor = useCallback( |
| 324 | (filePath: string) => { | 335 | (filePath: string) => { |
| 325 | const api = readNativeApi(); | 336 | const api = readLocalApi(); |
| 326 | if (!api) return; | 337 | if (!api) return; |
| 327 | const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; | 338 | const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; |
| 328 | void openInPreferredEditor(api, targetPath).catch((error) => { | 339 | void openInPreferredEditor(api, targetPath).catch((error) => { |
| ... | @@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 335 | const selectTurn = (turnId: TurnId) => { | 346 | const selectTurn = (turnId: TurnId) => { |
| 336 | if (!activeThread) return; | 347 | if (!activeThread) return; |
| 337 | void navigate({ | 348 | void navigate({ |
| 338 | to: "/$threadId", | 349 | to: "/$environmentId/$threadId", |
| 339 | params: { threadId: activeThread.id }, | 350 | params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), |
| 340 | search: (previous) => { | 351 | search: (previous) => { |
| 341 | const rest = stripDiffSearchParams(previous); | 352 | const rest = stripDiffSearchParams(previous); |
| 342 | return { ...rest, diff: "1", diffTurnId: turnId }; | 353 | return { ...rest, diff: "1", diffTurnId: turnId }; |
| ... | @@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 346 | const selectWholeConversation = () => { | 357 | const selectWholeConversation = () => { |
| 347 | if (!activeThread) return; | 358 | if (!activeThread) return; |
| 348 | void navigate({ | 359 | void navigate({ |
| 349 | to: "/$threadId", | 360 | to: "/$environmentId/$threadId", |
| 350 | params: { threadId: activeThread.id }, | 361 | params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), |
| 351 | search: (previous) => { | 362 | search: (previous) => { |
| 352 | const rest = stripDiffSearchParams(previous); | 363 | const rest = stripDiffSearchParams(previous); |
| 353 | return { ...rest, diff: "1" }; | 364 | return { ...rest, diff: "1" }; |
apps/web/src/components/GitActionsControl.browser.tsx+73-47| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | import { useState } from "react"; | 3 | import { useState } from "react"; |
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | 4 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| ... | @@ -5,10 +6,24 @@ import { render } from "vitest-browser-react"; | ... | @@ -5,10 +6,24 @@ import { render } from "vitest-browser-react"; |
| 5 | 6 | ||
| 6 | const THREAD_A = ThreadId.makeUnsafe("thread-a"); | 7 | const THREAD_A = ThreadId.makeUnsafe("thread-a"); |
| 7 | const THREAD_B = ThreadId.makeUnsafe("thread-b"); | 8 | const THREAD_B = ThreadId.makeUnsafe("thread-b"); |
| 9 | const ENVIRONMENT_ID = "environment-local" as never; | ||
| 8 | const GIT_CWD = "/repo/project"; | 10 | const GIT_CWD = "/repo/project"; |
| 9 | const BRANCH_NAME = "feature/toast-scope"; | 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 | const { | 25 | const { |
| 26 | activeRunStackedActionDeferredRef, | ||
| 12 | invalidateGitQueriesSpy, | 27 | invalidateGitQueriesSpy, |
| 13 | refreshGitStatusSpy, | 28 | refreshGitStatusSpy, |
| 14 | runStackedActionMutateAsyncSpy, | 29 | runStackedActionMutateAsyncSpy, |
| ... | @@ -18,9 +33,10 @@ const { | ... | @@ -18,9 +33,10 @@ const { |
| 18 | toastPromiseSpy, | 33 | toastPromiseSpy, |
| 19 | toastUpdateSpy, | 34 | toastUpdateSpy, |
| 20 | } = vi.hoisted(() => ({ | 35 | } = vi.hoisted(() => ({ |
| 36 | activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() }, | ||
| 21 | invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), | 37 | invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), |
| 22 | refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), | 38 | refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), |
| 23 | runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)), | 39 | runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), |
| 24 | setThreadBranchSpy: vi.fn(), | 40 | setThreadBranchSpy: vi.fn(), |
| 25 | toastAddSpy: vi.fn(() => "toast-1"), | 41 | toastAddSpy: vi.fn(() => "toast-1"), |
| 26 | toastCloseSpy: vi.fn(), | 42 | toastCloseSpy: vi.fn(), |
| ... | @@ -28,12 +44,8 @@ const { | ... | @@ -28,12 +44,8 @@ const { |
| 28 | toastUpdateSpy: vi.fn(), | 44 | toastUpdateSpy: vi.fn(), |
| 29 | })); | 45 | })); |
| 30 | 46 | ||
| 31 | vi.mock("@tanstack/react-query", async () => { | 47 | vi.mock("@tanstack/react-query", () => { |
| 32 | const actual = | ||
| 33 | await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query"); | ||
| 34 | |||
| 35 | return { | 48 | return { |
| 36 | ...actual, | ||
| 37 | useIsMutating: vi.fn(() => 0), | 49 | useIsMutating: vi.fn(() => 0), |
| 38 | useMutation: vi.fn((options: { __kind?: string }) => { | 50 | useMutation: vi.fn((options: { __kind?: string }) => { |
| 39 | if (options.__kind === "run-stacked-action") { | 51 | if (options.__kind === "run-stacked-action") { |
| ... | @@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => { | ... | @@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => { |
| 56 | isPending: false, | 68 | isPending: false, |
| 57 | }; | 69 | }; |
| 58 | }), | 70 | }), |
| 59 | useQuery: vi.fn((options: { queryKey?: string[] }) => { | 71 | useQuery: vi.fn(() => ({ data: null, error: null })), |
| 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 | }), | ||
| 80 | useQueryClient: vi.fn(() => ({})), | 72 | useQueryClient: vi.fn(() => ({})), |
| 81 | }; | 73 | }; |
| 82 | }); | 74 | }); |
| ... | @@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({ | ... | @@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({ |
| 123 | })), | 115 | })), |
| 124 | })); | 116 | })); |
| 125 | 117 | ||
| 126 | vi.mock("~/lib/utils", async () => { | 118 | vi.mock("~/localApi", () => ({ |
| 127 | const actual = await vi.importActual<typeof import("~/lib/utils")>("~/lib/utils"); | 119 | readLocalApi: vi.fn(() => null), |
| 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), | ||
| 138 | })); | 120 | })); |
| 139 | 121 | ||
| 140 | vi.mock("~/store", () => ({ | 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 | useStore: (selector: (state: unknown) => unknown) => | 136 | useStore: (selector: (state: unknown) => unknown) => |
| 142 | selector({ | 137 | selector({ |
| 143 | setThreadBranch: setThreadBranchSpy, | 138 | setThreadBranch: setThreadBranchSpy, |
| 144 | threadShellById: { | 139 | environmentStateById: { |
| 145 | [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, | 140 | [ENVIRONMENT_ID]: { |
| 146 | [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, | 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,7 +177,10 @@ function Harness() { |
| 168 | <button type="button" onClick={() => setActiveThreadId(THREAD_B)}> | 177 | <button type="button" onClick={() => setActiveThreadId(THREAD_B)}> |
| 169 | Switch thread | 178 | Switch thread |
| 170 | </button> | 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,6 +189,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { |
| 177 | afterEach(() => { | 189 | afterEach(() => { |
| 178 | vi.useRealTimers(); | 190 | vi.useRealTimers(); |
| 179 | vi.clearAllMocks(); | 191 | vi.clearAllMocks(); |
| 192 | activeRunStackedActionDeferredRef.current = createDeferredPromise<never>(); | ||
| 180 | document.body.innerHTML = ""; | 193 | document.body.innerHTML = ""; |
| 181 | }); | 194 | }); |
| 182 | 195 | ||
| ... | @@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => { | ... | @@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => { |
| 231 | }), | 244 | }), |
| 232 | ); | 245 | ); |
| 233 | } finally { | 246 | } finally { |
| 247 | activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); | ||
| 248 | await Promise.resolve(); | ||
| 249 | vi.useRealTimers(); | ||
| 234 | await screen.unmount(); | 250 | await screen.unmount(); |
| 235 | host.remove(); | 251 | host.remove(); |
| 236 | } | 252 | } |
| ... | @@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { | ... | @@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { |
| 248 | 264 | ||
| 249 | const host = document.createElement("div"); | 265 | const host = document.createElement("div"); |
| 250 | document.body.append(host); | 266 | document.body.append(host); |
| 251 | const screen = await render(<GitActionsControl gitCwd={GIT_CWD} activeThreadId={THREAD_A} />, { | 267 | const screen = await render( |
| 252 | container: host, | 268 | <GitActionsControl |
| 253 | }); | 269 | gitCwd={GIT_CWD} |
| 270 | activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, THREAD_A)} | ||
| 271 | />, | ||
| 272 | { | ||
| 273 | container: host, | ||
| 274 | }, | ||
| 275 | ); | ||
| 254 | 276 | ||
| 255 | try { | 277 | try { |
| 256 | window.dispatchEvent(new Event("focus")); | 278 | window.dispatchEvent(new Event("focus")); |
| ... | @@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { | ... | @@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { |
| 264 | 286 | ||
| 265 | await vi.advanceTimersByTimeAsync(1); | 287 | await vi.advanceTimersByTimeAsync(1); |
| 266 | expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); | 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 | } finally { | 293 | } finally { |
| 269 | if (originalVisibilityState) { | 294 | if (originalVisibilityState) { |
| 270 | Object.defineProperty(document, "visibilityState", originalVisibilityState); | 295 | Object.defineProperty(document, "visibilityState", originalVisibilityState); |
| 271 | } | 296 | } |
| 297 | vi.useRealTimers(); | ||
| 272 | await screen.unmount(); | 298 | await screen.unmount(); |
| 273 | host.remove(); | 299 | host.remove(); |
| 274 | } | 300 | } |
apps/web/src/components/GitActionsControl.tsx+42-20| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import { type ScopedThreadRef } from "@t3tools/contracts"; | ||
| 1 | import type { | 2 | import type { |
| 2 | GitActionProgressEvent, | 3 | GitActionProgressEvent, |
| 3 | GitRunStackedActionResult, | 4 | GitRunStackedActionResult, |
| 4 | GitStackedAction, | 5 | GitStackedAction, |
| 5 | GitStatusResult, | 6 | GitStatusResult, |
| 6 | ThreadId, | ||
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; | 8 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 9 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | 9 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| ... | @@ -49,12 +49,14 @@ import { | ... | @@ -49,12 +49,14 @@ import { |
| 49 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; | 49 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; |
| 50 | import { newCommandId, randomUUID } from "~/lib/utils"; | 50 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 51 | import { resolvePathLinkTarget } from "~/terminal-links"; | 51 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 52 | import { readNativeApi } from "~/nativeApi"; | 52 | import { readEnvironmentApi } from "~/environmentApi"; |
| 53 | import { readLocalApi } from "~/localApi"; | ||
| 53 | import { useStore } from "~/store"; | 54 | import { useStore } from "~/store"; |
| 55 | import { createThreadSelectorByRef } from "~/storeSelectors"; | ||
| 54 | 56 | ||
| 55 | interface GitActionsControlProps { | 57 | interface GitActionsControlProps { |
| 56 | gitCwd: string | null; | 58 | gitCwd: string | null; |
| 57 | activeThreadId: ThreadId | null; | 59 | activeThreadRef: ScopedThreadRef | null; |
| 58 | } | 60 | } |
| 59 | 61 | ||
| 60 | interface PendingDefaultBranchAction { | 62 | interface PendingDefaultBranchAction { |
| ... | @@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { | ... | @@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 206 | return <InfoIcon className={iconClassName} />; | 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 | const threadToastData = useMemo( | 214 | const threadToastData = useMemo( |
| 211 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), | 215 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 212 | [activeThreadId], | 216 | [activeThreadId], |
| 213 | ); | 217 | ); |
| 214 | const activeServerThread = useStore((store) => | 218 | const activeServerThreadSelector = useMemo( |
| 215 | activeThreadId ? store.threadShellById[activeThreadId] : undefined, | 219 | () => createThreadSelectorByRef(activeThreadRef), |
| 220 | [activeThreadRef], | ||
| 216 | ); | 221 | ); |
| 222 | const activeServerThread = useStore(activeServerThreadSelector); | ||
| 217 | const setThreadBranch = useStore((store) => store.setThreadBranch); | 223 | const setThreadBranch = useStore((store) => store.setThreadBranch); |
| 218 | const queryClient = useQueryClient(); | 224 | const queryClient = useQueryClient(); |
| 219 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); | 225 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| ... | @@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 246 | } | 252 | } |
| 247 | 253 | ||
| 248 | const worktreePath = activeServerThread.worktreePath; | 254 | const worktreePath = activeServerThread.worktreePath; |
| 249 | const api = readNativeApi(); | 255 | const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; |
| 250 | if (api) { | 256 | if (api) { |
| 251 | void api.orchestration | 257 | void api.orchestration |
| 252 | .dispatchCommand({ | 258 | .dispatchCommand({ |
| ... | @@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 261 | 267 | ||
| 262 | setThreadBranch(activeThreadId, branch, worktreePath); | 268 | setThreadBranch(activeThreadId, branch, worktreePath); |
| 263 | }, | 269 | }, |
| 264 | [activeServerThread, activeThreadId, setThreadBranch], | 270 | [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch], |
| 265 | ); | 271 | ); |
| 266 | 272 | ||
| 267 | const syncThreadBranchAfterGitAction = useCallback( | 273 | const syncThreadBranchAfterGitAction = useCallback( |
| ... | @@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 276 | [persistThreadBranchSync], | 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 | // Default to true while loading so we don't flash init controls. | 289 | // Default to true while loading so we don't flash init controls. |
| 281 | const isRepo = gitStatus?.isRepo ?? true; | 290 | const isRepo = gitStatus?.isRepo ?? true; |
| 282 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; | 291 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; |
| ... | @@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 287 | const allSelected = excludedFiles.size === 0; | 296 | const allSelected = excludedFiles.size === 0; |
| 288 | const noneSelected = selectedFiles.length === 0; | 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 | const runImmediateGitActionMutation = useMutation( | 303 | const runImmediateGitActionMutation = useMutation( |
| 293 | gitRunStackedActionMutationOptions({ | 304 | gitRunStackedActionMutationOptions({ |
| 305 | environmentId: activeEnvironmentId, | ||
| 294 | cwd: gitCwd, | 306 | cwd: gitCwd, |
| 295 | queryClient, | 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 | const isRunStackedActionRunning = | 314 | const isRunStackedActionRunning = |
| 301 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; | 315 | useIsMutating({ |
| 302 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; | 316 | mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), |
| 317 | }) > 0; | ||
| 318 | const isPullRunning = | ||
| 319 | useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; | ||
| 303 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; | 320 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; |
| 304 | 321 | ||
| 305 | useEffect(() => { | 322 | useEffect(() => { |
| ... | @@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 372 | } | 389 | } |
| 373 | refreshTimeout = window.setTimeout(() => { | 390 | refreshTimeout = window.setTimeout(() => { |
| 374 | refreshTimeout = null; | 391 | refreshTimeout = null; |
| 375 | void refreshGitStatus(gitCwd).catch(() => undefined); | 392 | void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( |
| 393 | () => undefined, | ||
| 394 | ); | ||
| 376 | }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); | 395 | }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); |
| 377 | }; | 396 | }; |
| 378 | const handleVisibilityChange = () => { | 397 | const handleVisibilityChange = () => { |
| ... | @@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 391 | window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); | 410 | window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); |
| 392 | document.removeEventListener("visibilitychange", handleVisibilityChange); | 411 | document.removeEventListener("visibilitychange", handleVisibilityChange); |
| 393 | }; | 412 | }; |
| 394 | }, [gitCwd]); | 413 | }, [activeEnvironmentId, gitCwd]); |
| 395 | 414 | ||
| 396 | const openExistingPr = useCallback(async () => { | 415 | const openExistingPr = useCallback(async () => { |
| 397 | const api = readNativeApi(); | 416 | const api = readLocalApi(); |
| 398 | if (!api) { | 417 | if (!api) { |
| 399 | toastManager.add({ | 418 | toastManager.add({ |
| 400 | type: "error", | 419 | type: "error", |
| ... | @@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 412 | }); | 431 | }); |
| 413 | return; | 432 | return; |
| 414 | } | 433 | } |
| 415 | void api.shell.openExternal(prUrl).catch((err) => { | 434 | void api.shell.openExternal(prUrl).catch((err: unknown) => { |
| 416 | toastManager.add({ | 435 | toastManager.add({ |
| 417 | type: "error", | 436 | type: "error", |
| 418 | title: "Unable to open PR link", | 437 | title: "Unable to open PR link", |
| ... | @@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 601 | toastActionProps = { | 620 | toastActionProps = { |
| 602 | children: toastCta.label, | 621 | children: toastCta.label, |
| 603 | onClick: () => { | 622 | onClick: () => { |
| 604 | const api = readNativeApi(); | 623 | const api = readLocalApi(); |
| 605 | if (!api) return; | 624 | if (!api) return; |
| 606 | closeResultToast(); | 625 | closeResultToast(); |
| 607 | void api.shell.openExternal(toastCta.url); | 626 | void api.shell.openExternal(toastCta.url); |
| ... | @@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 760 | 779 | ||
| 761 | const openChangedFileInEditor = useCallback( | 780 | const openChangedFileInEditor = useCallback( |
| 762 | (filePath: string) => { | 781 | (filePath: string) => { |
| 763 | const api = readNativeApi(); | 782 | const api = readLocalApi(); |
| 764 | if (!api || !gitCwd) { | 783 | if (!api || !gitCwd) { |
| 765 | toastManager.add({ | 784 | toastManager.add({ |
| 766 | type: "error", | 785 | type: "error", |
| ... | @@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 836 | <Menu | 855 | <Menu |
| 837 | onOpenChange={(open) => { | 856 | onOpenChange={(open) => { |
| 838 | if (open) { | 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,6 +2,7 @@ import "../index.css"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | DEFAULT_SERVER_SETTINGS, | 4 | DEFAULT_SERVER_SETTINGS, |
| 5 | EnvironmentId, | ||
| 5 | ORCHESTRATION_WS_METHODS, | 6 | ORCHESTRATION_WS_METHODS, |
| 6 | type MessageId, | 7 | type MessageId, |
| 7 | type OrchestrationReadModel, | 8 | type OrchestrationReadModel, |
| ... | @@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } | ... | @@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } |
| 18 | import { render } from "vitest-browser-react"; | 19 | import { render } from "vitest-browser-react"; |
| 19 | 20 | ||
| 20 | import { useComposerDraftStore } from "../composerDraftStore"; | 21 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 21 | import { __resetNativeApiForTests } from "../nativeApi"; | 22 | import { __resetLocalApiForTests } from "../localApi"; |
| 22 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | 23 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; |
| 23 | import { getServerConfig } from "../rpc/serverState"; | 24 | import { getServerConfig } from "../rpc/serverState"; |
| 24 | import { getRouter } from "../router"; | 25 | import { getRouter } from "../router"; |
| ... | @@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({ | ... | @@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({ |
| 34 | 35 | ||
| 35 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; | 36 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; |
| 36 | const PROJECT_ID = "project-1" as ProjectId; | 37 | const PROJECT_ID = "project-1" as ProjectId; |
| 38 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | ||
| 37 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | 39 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 38 | 40 | ||
| 39 | interface TestFixture { | 41 | interface TestFixture { |
| ... | @@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); | ... | @@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 49 | 51 | ||
| 50 | function createBaseServerConfig(): ServerConfig { | 52 | function createBaseServerConfig(): ServerConfig { |
| 51 | return { | 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 | cwd: "/repo/project", | 61 | cwd: "/repo/project", |
| 53 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | 62 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 54 | keybindings: [], | 63 | keybindings: [], |
| ... | @@ -155,6 +164,13 @@ function buildFixture(): TestFixture { | ... | @@ -155,6 +164,13 @@ function buildFixture(): TestFixture { |
| 155 | snapshot: createMinimalSnapshot(), | 164 | snapshot: createMinimalSnapshot(), |
| 156 | serverConfig: createBaseServerConfig(), | 165 | serverConfig: createBaseServerConfig(), |
| 157 | welcome: { | 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 | cwd: "/repo/project", | 174 | cwd: "/repo/project", |
| 159 | projectName: "Project", | 175 | projectName: "Project", |
| 160 | bootstrapProjectId: PROJECT_ID, | 176 | bootstrapProjectId: PROJECT_ID, |
| ... | @@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { | ... | @@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { |
| 286 | host.style.overflow = "hidden"; | 302 | host.style.overflow = "hidden"; |
| 287 | document.body.append(host); | 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 | const screen = await render( | 309 | const screen = await render( |
| 292 | <AppAtomRegistryProvider> | 310 | <AppAtomRegistryProvider> |
| ... | @@ -347,32 +365,17 @@ describe("Keybindings update toast", () => { | ... | @@ -347,32 +365,17 @@ describe("Keybindings update toast", () => { |
| 347 | return []; | 365 | return []; |
| 348 | }, | 366 | }, |
| 349 | }); | 367 | }); |
| 350 | await __resetNativeApiForTests(); | 368 | await __resetLocalApiForTests(); |
| 351 | localStorage.clear(); | 369 | localStorage.clear(); |
| 352 | document.body.innerHTML = ""; | 370 | document.body.innerHTML = ""; |
| 353 | useComposerDraftStore.setState({ | 371 | useComposerDraftStore.setState({ |
| 354 | draftsByThreadId: {}, | 372 | draftsByThreadKey: {}, |
| 355 | draftThreadsByThreadId: {}, | 373 | draftThreadsByThreadKey: {}, |
| 356 | projectDraftThreadIdByProjectId: {}, | 374 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 357 | }); | 375 | }); |
| 358 | useStore.setState({ | 376 | useStore.setState({ |
| 359 | projectIds: [], | 377 | activeEnvironmentId: null, |
| 360 | projectById: {}, | 378 | environmentStateById: {}, |
| 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, | ||
| 376 | }); | 379 | }); |
| 377 | }); | 380 | }); |
| 378 | 381 |
apps/web/src/components/PlanSidebar.tsx+6-3| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { memo, useState, useCallback } from "react"; | 1 | import { memo, useState, useCallback } from "react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | 3 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 3 | import { Badge } from "./ui/badge"; | 4 | import { Badge } from "./ui/badge"; |
| 4 | import { Button } from "./ui/button"; | 5 | import { Button } from "./ui/button"; |
| ... | @@ -24,7 +25,7 @@ import { | ... | @@ -24,7 +25,7 @@ import { |
| 24 | stripDisplayedPlanMarkdown, | 25 | stripDisplayedPlanMarkdown, |
| 25 | } from "../proposedPlan"; | 26 | } from "../proposedPlan"; |
| 26 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | 27 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 27 | import { readNativeApi } from "~/nativeApi"; | 28 | import { readEnvironmentApi } from "~/environmentApi"; |
| 28 | import { toastManager } from "./ui/toast"; | 29 | import { toastManager } from "./ui/toast"; |
| 29 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 30 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 30 | 31 | ||
| ... | @@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { | ... | @@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { |
| 53 | interface PlanSidebarProps { | 54 | interface PlanSidebarProps { |
| 54 | activePlan: ActivePlanState | null; | 55 | activePlan: ActivePlanState | null; |
| 55 | activeProposedPlan: LatestProposedPlanState | null; | 56 | activeProposedPlan: LatestProposedPlanState | null; |
| 57 | environmentId: EnvironmentId; | ||
| 56 | markdownCwd: string | undefined; | 58 | markdownCwd: string | undefined; |
| 57 | workspaceRoot: string | undefined; | 59 | workspaceRoot: string | undefined; |
| 58 | timestampFormat: TimestampFormat; | 60 | timestampFormat: TimestampFormat; |
| ... | @@ -62,6 +64,7 @@ interface PlanSidebarProps { | ... | @@ -62,6 +64,7 @@ interface PlanSidebarProps { |
| 62 | const PlanSidebar = memo(function PlanSidebar({ | 64 | const PlanSidebar = memo(function PlanSidebar({ |
| 63 | activePlan, | 65 | activePlan, |
| 64 | activeProposedPlan, | 66 | activeProposedPlan, |
| 67 | environmentId, | ||
| 65 | markdownCwd, | 68 | markdownCwd, |
| 66 | workspaceRoot, | 69 | workspaceRoot, |
| 67 | timestampFormat, | 70 | timestampFormat, |
| ... | @@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 87 | }, [planMarkdown]); | 90 | }, [planMarkdown]); |
| 88 | 91 | ||
| 89 | const handleSaveToWorkspace = useCallback(() => { | 92 | const handleSaveToWorkspace = useCallback(() => { |
| 90 | const api = readNativeApi(); | 93 | const api = readEnvironmentApi(environmentId); |
| 91 | if (!api || !workspaceRoot || !planMarkdown) return; | 94 | if (!api || !workspaceRoot || !planMarkdown) return; |
| 92 | const filename = buildProposedPlanMarkdownFilename(planMarkdown); | 95 | const filename = buildProposedPlanMarkdownFilename(planMarkdown); |
| 93 | setIsSavingToWorkspace(true); | 96 | setIsSavingToWorkspace(true); |
| ... | @@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 115 | () => setIsSavingToWorkspace(false), | 118 | () => setIsSavingToWorkspace(false), |
| 116 | () => setIsSavingToWorkspace(false), | 119 | () => setIsSavingToWorkspace(false), |
| 117 | ); | 120 | ); |
| 118 | }, [planMarkdown, workspaceRoot]); | 121 | }, [environmentId, planMarkdown, workspaceRoot]); |
| 119 | 122 | ||
| 120 | return ( | 123 | return ( |
| 121 | <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50"> | 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,4 +1,4 @@ |
| 1 | import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 3 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| ... | @@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; | ... | @@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; |
| 24 | 24 | ||
| 25 | interface PullRequestThreadDialogProps { | 25 | interface PullRequestThreadDialogProps { |
| 26 | open: boolean; | 26 | open: boolean; |
| 27 | environmentId: EnvironmentId; | ||
| 27 | threadId: ThreadId; | 28 | threadId: ThreadId; |
| 28 | cwd: string | null; | 29 | cwd: string | null; |
| 29 | initialReference: string | null; | 30 | initialReference: string | null; |
| ... | @@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { | ... | @@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { |
| 33 | 34 | ||
| 34 | export function PullRequestThreadDialog({ | 35 | export function PullRequestThreadDialog({ |
| 35 | open, | 36 | open, |
| 37 | environmentId, | ||
| 36 | threadId, | 38 | threadId, |
| 37 | cwd, | 39 | cwd, |
| 38 | initialReference, | 40 | initialReference, |
| ... | @@ -72,6 +74,7 @@ export function PullRequestThreadDialog({ | ... | @@ -72,6 +74,7 @@ export function PullRequestThreadDialog({ |
| 72 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); | 74 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); |
| 73 | const resolvePullRequestQuery = useQuery( | 75 | const resolvePullRequestQuery = useQuery( |
| 74 | gitResolvePullRequestQueryOptions({ | 76 | gitResolvePullRequestQueryOptions({ |
| 77 | environmentId, | ||
| 75 | cwd, | 78 | cwd, |
| 76 | reference: open ? parsedDebouncedReference : null, | 79 | reference: open ? parsedDebouncedReference : null, |
| 77 | }), | 80 | }), |
| ... | @@ -83,13 +86,14 @@ export function PullRequestThreadDialog({ | ... | @@ -83,13 +86,14 @@ export function PullRequestThreadDialog({ |
| 83 | const cached = queryClient.getQueryData<GitResolvePullRequestResult>([ | 86 | const cached = queryClient.getQueryData<GitResolvePullRequestResult>([ |
| 84 | "git", | 87 | "git", |
| 85 | "pull-request", | 88 | "pull-request", |
| 89 | environmentId, | ||
| 86 | cwd, | 90 | cwd, |
| 87 | parsedReference, | 91 | parsedReference, |
| 88 | ]); | 92 | ]); |
| 89 | return cached?.pullRequest ?? null; | 93 | return cached?.pullRequest ?? null; |
| 90 | }, [cwd, parsedReference, queryClient]); | 94 | }, [cwd, environmentId, parsedReference, queryClient]); |
| 91 | const preparePullRequestThreadMutation = useMutation( | 95 | const preparePullRequestThreadMutation = useMutation( |
| 92 | gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), | 96 | gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }), |
| 93 | ); | 97 | ); |
| 94 | 98 | ||
| 95 | const liveResolvedPullRequest = | 99 | const liveResolvedPullRequest = |
apps/web/src/components/Sidebar.logic.test.ts+5-1| ... | @@ -20,7 +20,7 @@ import { | ... | @@ -20,7 +20,7 @@ import { |
| 20 | sortThreadsForSidebar, | 20 | sortThreadsForSidebar, |
| 21 | THREAD_JUMP_HINT_SHOW_DELAY_MS, | 21 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 22 | } from "./Sidebar.logic"; | 22 | } from "./Sidebar.logic"; |
| 23 | import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; | 23 | import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 24 | import { | 24 | import { |
| 25 | DEFAULT_INTERACTION_MODE, | 25 | DEFAULT_INTERACTION_MODE, |
| 26 | DEFAULT_RUNTIME_MODE, | 26 | DEFAULT_RUNTIME_MODE, |
| ... | @@ -28,6 +28,8 @@ import { | ... | @@ -28,6 +28,8 @@ import { |
| 28 | type Thread, | 28 | type Thread, |
| 29 | } from "../types"; | 29 | } from "../types"; |
| 30 | 30 | ||
| 31 | const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); | ||
| 32 | |||
| 31 | function makeLatestTurn(overrides?: { | 33 | function makeLatestTurn(overrides?: { |
| 32 | completedAt?: string | null; | 34 | completedAt?: string | null; |
| 33 | startedAt?: string | null; | 35 | startedAt?: string | null; |
| ... | @@ -625,6 +627,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { | ... | @@ -625,6 +627,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { |
| 625 | const { defaultModelSelection, ...rest } = overrides; | 627 | const { defaultModelSelection, ...rest } = overrides; |
| 626 | return { | 628 | return { |
| 627 | id: ProjectId.makeUnsafe("project-1"), | 629 | id: ProjectId.makeUnsafe("project-1"), |
| 630 | environmentId: localEnvironmentId, | ||
| 628 | name: "Project", | 631 | name: "Project", |
| 629 | cwd: "/tmp/project", | 632 | cwd: "/tmp/project", |
| 630 | defaultModelSelection: { | 633 | defaultModelSelection: { |
| ... | @@ -642,6 +645,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { | ... | @@ -642,6 +645,7 @@ function makeProject(overrides: Partial<Project> = {}): Project { |
| 642 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 645 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 643 | return { | 646 | return { |
| 644 | id: ThreadId.makeUnsafe("thread-1"), | 647 | id: ThreadId.makeUnsafe("thread-1"), |
| 648 | environmentId: localEnvironmentId, | ||
| 645 | codexThreadId: null, | 649 | codexThreadId: null, |
| 646 | projectId: ProjectId.makeUnsafe("project-1"), | 650 | projectId: ProjectId.makeUnsafe("project-1"), |
| 647 | title: "Thread", | 651 | title: "Thread", |
apps/web/src/components/Sidebar.tsx+2083-1402| ... | @@ -12,20 +12,7 @@ import { | ... | @@ -12,20 +12,7 @@ import { |
| 12 | } from "lucide-react"; | 12 | } from "lucide-react"; |
| 13 | import { ProjectFavicon } from "./ProjectFavicon"; | 13 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 14 | import { autoAnimate } from "@formkit/auto-animate"; | 14 | import { autoAnimate } from "@formkit/auto-animate"; |
| 15 | import { | 15 | import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; |
| 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"; | ||
| 29 | import { useShallow } from "zustand/react/shallow"; | 16 | import { useShallow } from "zustand/react/shallow"; |
| 30 | import { | 17 | import { |
| 31 | DndContext, | 18 | DndContext, |
| ... | @@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities"; | ... | @@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities"; |
| 45 | import { | 32 | import { |
| 46 | DEFAULT_MODEL_BY_PROVIDER, | 33 | DEFAULT_MODEL_BY_PROVIDER, |
| 47 | type DesktopUpdateState, | 34 | type DesktopUpdateState, |
| 35 | type EnvironmentId, | ||
| 48 | ProjectId, | 36 | ProjectId, |
| 37 | type ScopedThreadRef, | ||
| 38 | type ThreadEnvMode, | ||
| 49 | ThreadId, | 39 | ThreadId, |
| 50 | type GitStatusResult, | 40 | type GitStatusResult, |
| 51 | } from "@t3tools/contracts"; | 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 | import { | 49 | import { |
| 54 | type SidebarProjectSortOrder, | 50 | type SidebarProjectSortOrder, |
| 55 | type SidebarThreadSortOrder, | 51 | type SidebarThreadSortOrder, |
| ... | @@ -58,7 +54,13 @@ import { isElectron } from "../env"; | ... | @@ -58,7 +54,13 @@ import { isElectron } from "../env"; |
| 58 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; | 54 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 59 | import { isTerminalFocused } from "../lib/terminalFocus"; | 55 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 60 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; | 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 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 64 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 63 | import { useUiStateStore } from "../uiStateStore"; | 65 | import { useUiStateStore } from "../uiStateStore"; |
| 64 | import { | 66 | import { |
| ... | @@ -70,11 +72,16 @@ import { | ... | @@ -70,11 +72,16 @@ import { |
| 70 | threadTraversalDirectionFromCommand, | 72 | threadTraversalDirectionFromCommand, |
| 71 | } from "../keybindings"; | 73 | } from "../keybindings"; |
| 72 | import { useGitStatus } from "../lib/gitStatusState"; | 74 | import { useGitStatus } from "../lib/gitStatusState"; |
| 73 | import { readNativeApi } from "../nativeApi"; | 75 | import { readLocalApi } from "../localApi"; |
| 74 | import { useComposerDraftStore } from "../composerDraftStore"; | 76 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 75 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 77 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 76 | 78 | ||
| 77 | import { useThreadActions } from "../hooks/useThreadActions"; | 79 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 80 | import { | ||
| 81 | buildThreadRouteParams, | ||
| 82 | resolveThreadRouteRef, | ||
| 83 | resolveThreadRouteTarget, | ||
| 84 | } from "../threadRoutes"; | ||
| 78 | import { toastManager } from "./ui/toast"; | 85 | import { toastManager } from "./ui/toast"; |
| 79 | import { formatRelativeTimeLabel } from "../timestampFormat"; | 86 | import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 80 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | 87 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| ... | @@ -109,8 +116,6 @@ import { | ... | @@ -109,8 +116,6 @@ import { |
| 109 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 116 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 110 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 117 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 111 | import { | 118 | import { |
| 112 | getVisibleSidebarThreadIds, | ||
| 113 | getVisibleThreadsForProject, | ||
| 114 | resolveAdjacentThreadId, | 119 | resolveAdjacentThreadId, |
| 115 | isContextMenuPointerDown, | 120 | isContextMenuPointerDown, |
| 116 | resolveProjectStatusIndicator, | 121 | resolveProjectStatusIndicator, |
| ... | @@ -123,12 +128,14 @@ import { | ... | @@ -123,12 +128,14 @@ import { |
| 123 | sortProjectsForSidebar, | 128 | sortProjectsForSidebar, |
| 124 | sortThreadsForSidebar, | 129 | sortThreadsForSidebar, |
| 125 | useThreadJumpHintVisibility, | 130 | useThreadJumpHintVisibility, |
| 131 | ThreadStatusPill, | ||
| 126 | } from "./Sidebar.logic"; | 132 | } from "./Sidebar.logic"; |
| 127 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; | 133 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 128 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 134 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 135 | import { readEnvironmentApi } from "../environmentApi"; | ||
| 129 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | 136 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 130 | import { useServerKeybindings } from "../rpc/serverState"; | 137 | import { useServerKeybindings } from "../rpc/serverState"; |
| 131 | import type { Project } from "../types"; | 138 | import type { Project, SidebarThreadSummary } from "../types"; |
| 132 | const THREAD_PREVIEW_LIMIT = 6; | 139 | const THREAD_PREVIEW_LIMIT = 6; |
| 133 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { | 140 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 134 | updated_at: "Last user message", | 141 | updated_at: "Last user message", |
| ... | @@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { | ... | @@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { |
| 143 | duration: 180, | 150 | duration: 180, |
| 144 | easing: "ease-out", | 151 | easing: "ease-out", |
| 145 | } as const; | 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 | type SidebarProjectSnapshot = Project & { | 203 | type SidebarProjectSnapshot = Project & { |
| 148 | expanded: boolean; | 204 | projectKey: string; |
| 149 | }; | 205 | }; |
| 150 | interface TerminalStatusIndicator { | 206 | interface TerminalStatusIndicator { |
| 151 | label: "Terminal process running"; | 207 | label: "Terminal process running"; |
| ... | @@ -166,7 +222,7 @@ function ThreadStatusLabel({ | ... | @@ -166,7 +222,7 @@ function ThreadStatusLabel({ |
| 166 | status, | 222 | status, |
| 167 | compact = false, | 223 | compact = false, |
| 168 | }: { | 224 | }: { |
| 169 | status: NonNullable<ReturnType<typeof resolveThreadStatusPill>>; | 225 | status: ThreadStatusPill; |
| 170 | compact?: boolean; | 226 | compact?: boolean; |
| 171 | }) { | 227 | }) { |
| 172 | if (compact) { | 228 | if (compact) { |
| ... | @@ -255,57 +311,81 @@ function resolveThreadPr( | ... | @@ -255,57 +311,81 @@ function resolveThreadPr( |
| 255 | } | 311 | } |
| 256 | 312 | ||
| 257 | interface SidebarThreadRowProps { | 313 | interface SidebarThreadRowProps { |
| 258 | threadId: ThreadId; | 314 | thread: SidebarThreadSummary; |
| 259 | projectCwd: string | null; | 315 | projectCwd: string | null; |
| 260 | orderedProjectThreadIds: readonly ThreadId[]; | 316 | orderedProjectThreadKeys: readonly string[]; |
| 261 | routeThreadId: ThreadId | null; | 317 | isActive: boolean; |
| 262 | selectedThreadIds: ReadonlySet<ThreadId>; | ||
| 263 | showThreadJumpHints: boolean; | ||
| 264 | jumpLabel: string | null; | 318 | jumpLabel: string | null; |
| 265 | appSettingsConfirmThreadArchive: boolean; | 319 | appSettingsConfirmThreadArchive: boolean; |
| 266 | renamingThreadId: ThreadId | null; | 320 | renamingThreadKey: string | null; |
| 267 | renamingTitle: string; | 321 | renamingTitle: string; |
| 268 | setRenamingTitle: (title: string) => void; | 322 | setRenamingTitle: (title: string) => void; |
| 269 | onRenamingInputMount: (element: HTMLInputElement | null) => void; | 323 | renamingInputRef: React.RefObject<HTMLInputElement | null>; |
| 270 | hasRenameCommitted: () => boolean; | 324 | renamingCommittedRef: React.RefObject<boolean>; |
| 271 | markRenameCommitted: () => void; | 325 | confirmingArchiveThreadKey: string | null; |
| 272 | confirmingArchiveThreadId: ThreadId | null; | 326 | setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>; |
| 273 | setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>; | 327 | confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>; |
| 274 | confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>; | ||
| 275 | handleThreadClick: ( | 328 | handleThreadClick: ( |
| 276 | event: MouseEvent, | 329 | event: React.MouseEvent, |
| 277 | threadId: ThreadId, | 330 | threadRef: ScopedThreadRef, |
| 278 | orderedProjectThreadIds: readonly ThreadId[], | 331 | orderedProjectThreadKeys: readonly string[], |
| 279 | ) => void; | 332 | ) => void; |
| 280 | navigateToThread: (threadId: ThreadId) => void; | 333 | navigateToThread: (threadRef: ScopedThreadRef) => void; |
| 281 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; | 334 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; |
| 282 | handleThreadContextMenu: ( | 335 | handleThreadContextMenu: ( |
| 283 | threadId: ThreadId, | 336 | threadRef: ScopedThreadRef, |
| 284 | position: { x: number; y: number }, | 337 | position: { x: number; y: number }, |
| 285 | ) => Promise<void>; | 338 | ) => Promise<void>; |
| 286 | clearSelection: () => void; | 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 | cancelRename: () => void; | 345 | cancelRename: () => void; |
| 289 | attemptArchiveThread: (threadId: ThreadId) => Promise<void>; | 346 | attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>; |
| 290 | openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void; | 347 | openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void; |
| 291 | } | 348 | } |
| 292 | 349 | ||
| 293 | function SidebarThreadRow(props: SidebarThreadRowProps) { | 350 | const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 294 | const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); | 351 | const { |
| 295 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); | 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 | const runningTerminalIds = useTerminalStateStore( | 380 | const runningTerminalIds = useTerminalStateStore( |
| 297 | (state) => | 381 | (state) => |
| 298 | selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, | 382 | selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, |
| 299 | ); | 383 | ); |
| 300 | const gitCwd = thread?.worktreePath ?? props.projectCwd; | 384 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 301 | const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); | 385 | const gitStatus = useGitStatus({ |
| 302 | 386 | environmentId: thread.environmentId, | |
| 303 | if (!thread) { | 387 | cwd: thread.branch != null ? gitCwd : null, |
| 304 | return null; | 388 | }); |
| 305 | } | ||
| 306 | |||
| 307 | const isActive = props.routeThreadId === thread.id; | ||
| 308 | const isSelected = props.selectedThreadIds.has(thread.id); | ||
| 309 | const isHighlighted = isActive || isSelected; | 389 | const isHighlighted = isActive || isSelected; |
| 310 | const isThreadRunning = | 390 | const isThreadRunning = |
| 311 | thread.session?.status === "running" && thread.session.activeTurnId != null; | 391 | thread.session?.status === "running" && thread.session.activeTurnId != null; |
| ... | @@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 318 | const pr = resolveThreadPr(thread.branch, gitStatus.data); | 398 | const pr = resolveThreadPr(thread.branch, gitStatus.data); |
| 319 | const prStatus = prStatusIndicator(pr); | 399 | const prStatus = prStatusIndicator(pr); |
| 320 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); | 400 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 321 | const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; | 401 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; |
| 322 | const threadMetaClassName = isConfirmingArchive | 402 | const threadMetaClassName = isConfirmingArchive |
| 323 | ? "pointer-events-none opacity-0" | 403 | ? "pointer-events-none opacity-0" |
| 324 | : !isThreadRunning | 404 | : !isThreadRunning |
| 325 | ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" | 405 | ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" |
| 326 | : "pointer-events-none"; | 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 | return ( | 559 | return ( |
| 329 | <SidebarMenuSubItem | 560 | <SidebarMenuSubItem |
| 330 | className="w-full" | 561 | className="w-full" |
| 331 | data-thread-item | 562 | data-thread-item |
| 332 | onMouseLeave={() => { | 563 | onMouseLeave={handleMouseLeave} |
| 333 | props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); | 564 | onBlurCapture={handleBlurCapture} |
| 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 | }} | ||
| 344 | > | 565 | > |
| 345 | <SidebarMenuSubButton | 566 | <SidebarMenuSubButton |
| 346 | render={<div role="button" tabIndex={0} />} | 567 | render={rowButtonRender} |
| 347 | size="sm" | 568 | size="sm" |
| 348 | isActive={isActive} | 569 | isActive={isActive} |
| 349 | data-testid={`thread-row-${thread.id}`} | 570 | data-testid={`thread-row-${thread.id}`} |
| ... | @@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 351 | isActive, | 572 | isActive, |
| 352 | isSelected, | 573 | isSelected, |
| 353 | })} relative isolate`} | 574 | })} relative isolate`} |
| 354 | onClick={(event) => { | 575 | onClick={handleRowClick} |
| 355 | props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); | 576 | onKeyDown={handleRowKeyDown} |
| 356 | }} | 577 | onContextMenu={handleRowContextMenu} |
| 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 | }} | ||
| 379 | > | 578 | > |
| 380 | <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> | 579 | <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> |
| 381 | {prStatus && ( | 580 | {prStatus && ( |
| ... | @@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 386 | type="button" | 585 | type="button" |
| 387 | aria-label={prStatus.tooltip} | 586 | aria-label={prStatus.tooltip} |
| 388 | className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} | 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) => { | 588 | onClick={handlePrClick} |
| 390 | props.openPrLink(event, prStatus.url); | ||
| 391 | }} | ||
| 392 | > | 589 | > |
| 393 | <GitPullRequestIcon className="size-3" /> | 590 | <GitPullRequestIcon className="size-3" /> |
| 394 | </button> | 591 | </button> |
| ... | @@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 398 | </Tooltip> | 595 | </Tooltip> |
| 399 | )} | 596 | )} |
| 400 | {threadStatus && <ThreadStatusLabel status={threadStatus} />} | 597 | {threadStatus && <ThreadStatusLabel status={threadStatus} />} |
| 401 | {props.renamingThreadId === thread.id ? ( | 598 | {renamingThreadKey === threadKey ? ( |
| 402 | <input | 599 | <input |
| 403 | ref={props.onRenamingInputMount} | 600 | ref={handleRenameInputRef} |
| 404 | className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" | 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} | 602 | value={renamingTitle} |
| 406 | onChange={(event) => props.setRenamingTitle(event.target.value)} | 603 | onChange={handleRenameInputChange} |
| 407 | onKeyDown={(event) => { | 604 | onKeyDown={handleRenameInputKeyDown} |
| 408 | event.stopPropagation(); | 605 | onBlur={handleRenameInputBlur} |
| 409 | if (event.key === "Enter") { | 606 | onClick={handleRenameInputClick} |
| 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()} | ||
| 425 | /> | 607 | /> |
| 426 | ) : ( | 608 | ) : ( |
| 427 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> | 609 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> |
| ... | @@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 441 | <div className="flex min-w-12 justify-end"> | 623 | <div className="flex min-w-12 justify-end"> |
| 442 | {isConfirmingArchive ? ( | 624 | {isConfirmingArchive ? ( |
| 443 | <button | 625 | <button |
| 444 | ref={(element) => { | 626 | ref={handleConfirmArchiveRef} |
| 445 | if (element) { | ||
| 446 | props.confirmArchiveButtonRefs.current.set(thread.id, element); | ||
| 447 | } else { | ||
| 448 | props.confirmArchiveButtonRefs.current.delete(thread.id); | ||
| 449 | } | ||
| 450 | }} | ||
| 451 | type="button" | 627 | type="button" |
| 452 | data-thread-selection-safe | 628 | data-thread-selection-safe |
| 453 | data-testid={`thread-archive-confirm-${thread.id}`} | 629 | data-testid={`thread-archive-confirm-${thread.id}`} |
| 454 | aria-label={`Confirm archive ${thread.title}`} | 630 | aria-label={`Confirm archive ${thread.title}`} |
| 455 | 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" | 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) => { | 632 | onPointerDown={stopPropagationOnPointerDown} |
| 457 | event.stopPropagation(); | 633 | onClick={handleConfirmArchiveClick} |
| 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 | }} | ||
| 467 | > | 634 | > |
| 468 | Confirm | 635 | Confirm |
| 469 | </button> | 636 | </button> |
| 470 | ) : !isThreadRunning ? ( | 637 | ) : !isThreadRunning ? ( |
| 471 | props.appSettingsConfirmThreadArchive ? ( | 638 | appSettingsConfirmThreadArchive ? ( |
| 472 | <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"> | 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 | <button | 640 | <button |
| 474 | type="button" | 641 | type="button" |
| ... | @@ -476,17 +643,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -476,17 +643,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 476 | data-testid={`thread-archive-${thread.id}`} | 643 | data-testid={`thread-archive-${thread.id}`} |
| 477 | aria-label={`Archive ${thread.title}`} | 644 | aria-label={`Archive ${thread.title}`} |
| 478 | 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" | 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) => { | 646 | onPointerDown={stopPropagationOnPointerDown} |
| 480 | event.stopPropagation(); | 647 | onClick={handleStartArchiveConfirmation} |
| 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 | }} | ||
| 490 | > | 648 | > |
| 491 | <ArchiveIcon className="size-3.5" /> | 649 | <ArchiveIcon className="size-3.5" /> |
| 492 | </button> | 650 | </button> |
| ... | @@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 502 | data-testid={`thread-archive-${thread.id}`} | 660 | data-testid={`thread-archive-${thread.id}`} |
| 503 | aria-label={`Archive ${thread.title}`} | 661 | aria-label={`Archive ${thread.title}`} |
| 504 | 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" | 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) => { | 663 | onPointerDown={stopPropagationOnPointerDown} |
| 506 | event.stopPropagation(); | 664 | onClick={handleArchiveImmediateClick} |
| 507 | }} | ||
| 508 | onClick={(event) => { | ||
| 509 | event.preventDefault(); | ||
| 510 | event.stopPropagation(); | ||
| 511 | void props.attemptArchiveThread(thread.id); | ||
| 512 | }} | ||
| 513 | > | 665 | > |
| 514 | <ArchiveIcon className="size-3.5" /> | 666 | <ArchiveIcon className="size-3.5" /> |
| 515 | </button> | 667 | </button> |
| ... | @@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 521 | ) | 673 | ) |
| 522 | ) : null} | 674 | ) : null} |
| 523 | <span className={threadMetaClassName}> | 675 | <span className={threadMetaClassName}> |
| 524 | {props.showThreadJumpHints && props.jumpLabel ? ( | 676 | {jumpLabel ? ( |
| 525 | <span | 677 | <span |
| 526 | 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" | 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 | </span> | 682 | </span> |
| 531 | ) : ( | 683 | ) : ( |
| 532 | <span | 684 | <span |
| ... | @@ -545,248 +697,288 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -545,248 +697,288 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 545 | </SidebarMenuSubButton> | 697 | </SidebarMenuSubButton> |
| 546 | </SidebarMenuSubItem> | 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() { | 749 | const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 551 | return ( | 750 | props: SidebarProjectThreadListProps, |
| 552 | <svg | 751 | ) { |
| 553 | aria-label="T3" | 752 | const { |
| 554 | className="h-2.5 w-auto shrink-0 text-foreground" | 753 | projectKey, |
| 555 | viewBox="15.5309 37 94.3941 56.96" | 754 | projectExpanded, |
| 556 | xmlns="http://www.w3.org/2000/svg" | 755 | hasOverflowingThreads, |
| 557 | > | 756 | hiddenThreadStatus, |
| 558 | <path | 757 | orderedProjectThreadKeys, |
| 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" | 758 | renderedThreads, |
| 560 | fill="currentColor" | 759 | showEmptyThreadState, |
| 561 | /> | 760 | shouldShowThreadPanel, |
| 562 | </svg> | 761 | isThreadListExpanded, |
| 563 | ); | 762 | projectCwd, |
| 564 | } | 763 | activeRouteThreadKey, |
| 565 | 764 | threadJumpLabelByKey, | |
| 566 | type SortableProjectHandleProps = Pick< | 765 | appSettingsConfirmThreadArchive, |
| 567 | ReturnType<typeof useSortable>, | 766 | renamingThreadKey, |
| 568 | "attributes" | "listeners" | "setActivatorNodeRef" | 767 | renamingTitle, |
| 569 | >; | 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 | return ( | 790 | return ( |
| 583 | <Menu> | 791 | <SidebarMenuSub |
| 584 | <Tooltip> | 792 | ref={attachThreadListAutoAnimateRef} |
| 585 | <TooltipTrigger | 793 | className="mx-1 my-0 w-full translate-x-0 gap-0.5 overflow-hidden px-1.5 py-0" |
| 586 | render={ | 794 | > |
| 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" /> | 795 | {shouldShowThreadPanel && showEmptyThreadState ? ( |
| 588 | } | 796 | <SidebarMenuSubItem className="w-full" data-thread-selection-safe> |
| 589 | > | 797 | <div |
| 590 | <ArrowUpDownIcon className="size-3.5" /> | 798 | data-thread-selection-safe |
| 591 | </TooltipTrigger> | 799 | className="flex h-6 w-full translate-x-0 items-center px-2 text-left text-[10px] text-muted-foreground/60" |
| 592 | <TooltipPopup side="right">Sort projects</TooltipPopup> | 800 | > |
| 593 | </Tooltip> | 801 | <span>No threads yet</span> |
| 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 | ||
| 598 | </div> | 802 | </div> |
| 599 | <MenuRadioGroup | 803 | </SidebarMenuSubItem> |
| 600 | value={projectSortOrder} | 804 | ) : null} |
| 601 | onValueChange={(value) => { | 805 | {shouldShowThreadPanel && |
| 602 | onProjectSortOrderChange(value as SidebarProjectSortOrder); | 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( | 849 | <span className="flex min-w-0 flex-1 items-center gap-2"> |
| 606 | ([value, label]) => ( | 850 | {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />} |
| 607 | <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> | 851 | <span>Show more</span> |
| 608 | {label} | 852 | </span> |
| 609 | </MenuRadioItem> | 853 | </SidebarMenuSubButton> |
| 610 | ), | 854 | </SidebarMenuSubItem> |
| 611 | )} | 855 | )} |
| 612 | </MenuRadioGroup> | 856 | {projectExpanded && hasOverflowingThreads && isThreadListExpanded && ( |
| 613 | </MenuGroup> | 857 | <SidebarMenuSubItem className="w-full"> |
| 614 | <MenuGroup> | 858 | <SidebarMenuSubButton |
| 615 | <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground"> | 859 | render={showLessButtonRender} |
| 616 | Sort threads | 860 | data-thread-selection-safe |
| 617 | </div> | 861 | size="sm" |
| 618 | <MenuRadioGroup | 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" |
| 619 | value={threadSortOrder} | 863 | onClick={() => { |
| 620 | onValueChange={(value) => { | 864 | collapseThreadListForProject(projectKey); |
| 621 | onThreadSortOrderChange(value as SidebarThreadSortOrder); | ||
| 622 | }} | 865 | }} |
| 623 | > | 866 | > |
| 624 | {( | 867 | <span>Show less</span> |
| 625 | Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> | 868 | </SidebarMenuSubButton> |
| 626 | ).map(([value, label]) => ( | 869 | </SidebarMenuSubItem> |
| 627 | <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> | 870 | )} |
| 628 | {label} | 871 | </SidebarMenuSub> |
| 629 | </MenuRadioItem> | ||
| 630 | ))} | ||
| 631 | </MenuRadioGroup> | ||
| 632 | </MenuGroup> | ||
| 633 | </MenuPopup> | ||
| 634 | </Menu> | ||
| 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({ | 894 | const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { |
| 639 | projectId, | ||
| 640 | disabled = false, | ||
| 641 | children, | ||
| 642 | }: { | ||
| 643 | projectId: ProjectId; | ||
| 644 | disabled?: boolean; | ||
| 645 | children: (handleProps: SortableProjectHandleProps) => ReactNode; | ||
| 646 | }) { | ||
| 647 | const { | 895 | const { |
| 648 | attributes, | 896 | project, |
| 649 | listeners, | 897 | isThreadListExpanded, |
| 650 | setActivatorNodeRef, | 898 | activeRouteThreadKey, |
| 651 | setNodeRef, | 899 | newThreadShortcutLabel, |
| 652 | transform, | 900 | handleNewThread, |
| 653 | transition, | 901 | archiveThread, |
| 654 | isDragging, | 902 | deleteThread, |
| 655 | isOver, | 903 | threadJumpLabelByKey, |
| 656 | } = useSortable({ id: projectId, disabled }); | 904 | attachThreadListAutoAnimateRef, |
| 657 | return ( | 905 | expandThreadListForProject, |
| 658 | <li | 906 | collapseThreadListForProject, |
| 659 | ref={setNodeRef} | 907 | dragInProgressRef, |
| 660 | style={{ | 908 | suppressProjectClickAfterDragRef, |
| 661 | transform: CSS.Translate.toString(transform), | 909 | suppressProjectClickForContextMenuRef, |
| 662 | transition, | 910 | isManualProjectSorting, |
| 663 | }} | 911 | dragHandleProps, |
| 664 | className={`group/menu-item relative rounded-md ${ | 912 | } = props; |
| 665 | isDragging ? "z-20 opacity-80" : "" | 913 | const threadSortOrder = useSettings<SidebarThreadSortOrder>( |
| 666 | } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`} | 914 | (settings) => settings.sidebarThreadSortOrder, |
| 667 | data-sidebar="menu-item" | ||
| 668 | data-slot="sidebar-menu-item" | ||
| 669 | > | ||
| 670 | {children({ attributes, listeners, setActivatorNodeRef })} | ||
| 671 | </li> | ||
| 672 | ); | 915 | ); |
| 673 | } | 916 | const appSettingsConfirmThreadDelete = useSettings<boolean>( |
| 674 | 917 | (settings) => settings.confirmThreadDelete, | |
| 675 | export default function Sidebar() { | 918 | ); |
| 676 | const projectIds = useStore((store) => store.projectIds); | 919 | const appSettingsConfirmThreadArchive = useSettings<boolean>( |
| 677 | const projectById = useStore((store) => store.projectById); | 920 | (settings) => settings.confirmThreadArchive, |
| 678 | const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); | 921 | ); |
| 679 | const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); | 922 | const defaultThreadEnvMode = useSettings<ThreadEnvMode>( |
| 680 | const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( | 923 | (settings) => settings.defaultThreadEnvMode, |
| 681 | useShallow((store) => ({ | 924 | ); |
| 682 | projectExpandedById: store.projectExpandedById, | 925 | const router = useRouter(); |
| 683 | projectOrder: store.projectOrder, | 926 | const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); |
| 684 | threadLastVisitedAtById: store.threadLastVisitedAtById, | 927 | const toggleProject = useUiStateStore((state) => state.toggleProject); |
| 685 | })), | 928 | const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); |
| 686 | ); | 929 | const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); |
| 687 | const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); | 930 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| 688 | const toggleProject = useUiStateStore((store) => store.toggleProject); | 931 | const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); |
| 689 | const reorderProjects = useUiStateStore((store) => store.reorderProjects); | 932 | const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); |
| 690 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); | 933 | const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); |
| 691 | const getDraftThreadByProjectId = useComposerDraftStore( | 934 | const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread); |
| 692 | (store) => store.getDraftThreadByProjectId, | 935 | const getDraftThreadByProjectRef = useComposerDraftStore( |
| 936 | (state) => state.getDraftThreadByProjectRef, | ||
| 693 | ); | 937 | ); |
| 694 | const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); | ||
| 695 | const clearProjectDraftThreadId = useComposerDraftStore( | 938 | const clearProjectDraftThreadId = useComposerDraftStore( |
| 696 | (store) => store.clearProjectDraftThreadId, | 939 | (state) => state.clearProjectDraftThreadId, |
| 697 | ); | 940 | ); |
| 698 | const navigate = useNavigate(); | 941 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ |
| 699 | const pathname = useLocation({ select: (loc) => loc.pathname }); | 942 | threadId: ThreadId; |
| 700 | const isOnSettings = pathname.startsWith("/settings"); | 943 | }>({ |
| 701 | const appSettings = useSettings(); | 944 | onCopy: (ctx) => { |
| 702 | const { updateSettings } = useUpdateSettings(); | 945 | toastManager.add({ |
| 703 | const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); | 946 | type: "success", |
| 704 | const { archiveThread, deleteThread } = useThreadActions(); | 947 | title: "Thread ID copied", |
| 705 | const routeThreadId = useParams({ | 948 | description: ctx.threadId, |
| 706 | strict: false, | 949 | }); |
| 707 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | 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(); | 959 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ |
| 710 | const [addingProject, setAddingProject] = useState(false); | 960 | path: string; |
| 711 | const [newCwd, setNewCwd] = useState(""); | 961 | }>({ |
| 712 | const [isPickingFolder, setIsPickingFolder] = useState(false); | 962 | onCopy: (ctx) => { |
| 713 | const [isAddingProject, setIsAddingProject] = useState(false); | 963 | toastManager.add({ |
| 714 | const [addProjectError, setAddProjectError] = useState<string | null>(null); | 964 | type: "success", |
| 715 | const addProjectInputRef = useRef<HTMLInputElement | null>(null); | 965 | title: "Path copied", |
| 716 | const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null); | 966 | description: ctx.path, |
| 717 | const [renamingProjectTitle, setRenamingProjectTitle] = useState(""); | 967 | }); |
| 718 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); | 968 | }, |
| 719 | const [renamingTitle, setRenamingTitle] = useState(""); | 969 | onError: (error) => { |
| 720 | const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null); | 970 | toastManager.add({ |
| 721 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< | 971 | type: "error", |
| 722 | ReadonlySet<ProjectId> | 972 | title: "Failed to copy path", |
| 723 | >(() => new Set()); | 973 | description: error instanceof Error ? error.message : "An error occurred.", |
| 724 | const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); | 974 | }); |
| 725 | const projectRenamingCommittedRef = useRef(false); | 975 | }, |
| 726 | const projectRenamingInputRef = useRef<HTMLInputElement | null>(null); | 976 | }); |
| 727 | const renamingCommittedRef = useRef(false); | 977 | const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => { |
| 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) => { | ||
| 786 | event.preventDefault(); | 978 | event.preventDefault(); |
| 787 | event.stopPropagation(); | 979 | event.stopPropagation(); |
| 788 | 980 | ||
| 789 | const api = readNativeApi(); | 981 | const api = readLocalApi(); |
| 790 | if (!api) { | 982 | if (!api) { |
| 791 | toastManager.add({ | 983 | toastManager.add({ |
| 792 | type: "error", | 984 | type: "error", |
| ... | @@ -803,222 +995,495 @@ export default function Sidebar() { | ... | @@ -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( | 1083 | const pinnedCollapsedThread = useMemo(() => { |
| 808 | async (threadId: ThreadId) => { | 1084 | const activeThreadKey = activeRouteThreadKey ?? undefined; |
| 809 | try { | 1085 | if (!activeThreadKey || projectExpanded) { |
| 810 | await archiveThread(threadId); | 1086 | return null; |
| 811 | } catch (error) { | 1087 | } |
| 812 | toastManager.add({ | 1088 | return ( |
| 813 | type: "error", | 1089 | visibleProjectThreads.find( |
| 814 | title: "Failed to archive thread", | 1090 | (thread) => |
| 815 | description: error instanceof Error ? error.message : "An error occurred.", | 1091 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, |
| 816 | }); | 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( | 1192 | const handleProjectButtonKeyDown = useCallback( |
| 823 | (projectId: ProjectId) => { | 1193 | (event: React.KeyboardEvent<HTMLButtonElement>) => { |
| 824 | const latestThread = sortThreadsForSidebar( | 1194 | if (event.key !== "Enter" && event.key !== " ") return; |
| 825 | (threadIdsByProjectId[projectId] ?? []) | 1195 | event.preventDefault(); |
| 826 | .map((threadId) => sidebarThreadsById[threadId]) | 1196 | if (dragInProgressRef.current) { |
| 827 | .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined) | 1197 | return; |
| 828 | .filter((thread) => thread.archivedAt === null), | 1198 | } |
| 829 | appSettings.sidebarThreadSortOrder, | 1199 | toggleProject(project.projectKey); |
| 830 | )[0]; | ||
| 831 | if (!latestThread) return; | ||
| 832 | |||
| 833 | void navigate({ | ||
| 834 | to: "/$threadId", | ||
| 835 | params: { threadId: latestThread.id }, | ||
| 836 | }); | ||
| 837 | }, | 1200 | }, |
| 838 | [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId], | 1201 | [dragInProgressRef, project.projectKey, toggleProject], |
| 839 | ); | 1202 | ); |
| 840 | 1203 | ||
| 841 | const addProjectFromPath = useCallback( | 1204 | const handleProjectButtonPointerDownCapture = useCallback( |
| 842 | async (rawCwd: string) => { | 1205 | (event: React.PointerEvent<HTMLButtonElement>) => { |
| 843 | const cwd = rawCwd.trim(); | 1206 | suppressProjectClickForContextMenuRef.current = false; |
| 844 | if (!cwd || isAddingProject) return; | 1207 | if ( |
| 845 | const api = readNativeApi(); | 1208 | isContextMenuPointerDown({ |
| 846 | if (!api) return; | 1209 | button: event.button, |
| 1210 | ctrlKey: event.ctrlKey, | ||
| 1211 | isMac: isMacPlatform(navigator.platform), | ||
| 1212 | }) | ||
| 1213 | ) { | ||
| 1214 | event.stopPropagation(); | ||
| 1215 | } | ||
| 847 | 1216 | ||
| 848 | setIsAddingProject(true); | 1217 | suppressProjectClickAfterDragRef.current = false; |
| 849 | const finishAddingProject = () => { | 1218 | }, |
| 850 | setIsAddingProject(false); | 1219 | [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], |
| 851 | setNewCwd(""); | 1220 | ); |
| 852 | setAddProjectError(null); | ||
| 853 | setAddingProject(false); | ||
| 854 | }; | ||
| 855 | 1221 | ||
| 856 | const existing = projects.find((project) => project.cwd === cwd); | 1222 | const handleProjectButtonContextMenu = useCallback( |
| 857 | if (existing) { | 1223 | (event: React.MouseEvent<HTMLButtonElement>) => { |
| 858 | focusMostRecentThreadForProject(existing.id); | 1224 | event.preventDefault(); |
| 859 | finishAddingProject(); | 1225 | suppressProjectClickForContextMenuRef.current = true; |
| 860 | return; | 1226 | void (async () => { |
| 861 | } | 1227 | const api = readLocalApi(); |
| 1228 | if (!api) return; | ||
| 862 | 1229 | ||
| 863 | const projectId = newProjectId(); | 1230 | const clicked = await api.contextMenu.show( |
| 864 | const createdAt = new Date().toISOString(); | 1231 | [ |
| 865 | const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; | 1232 | { id: "copy-path", label: "Copy Project Path" }, |
| 866 | try { | 1233 | { id: "delete", label: "Remove project", destructive: true }, |
| 867 | await api.orchestration.dispatchCommand({ | 1234 | ], |
| 868 | type: "project.create", | 1235 | { |
| 869 | commandId: newCommandId(), | 1236 | x: event.clientX, |
| 870 | projectId, | 1237 | y: event.clientY, |
| 871 | title, | ||
| 872 | workspaceRoot: cwd, | ||
| 873 | defaultModelSelection: { | ||
| 874 | provider: "codex", | ||
| 875 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 876 | }, | 1238 | }, |
| 877 | createdAt, | 1239 | ); |
| 878 | }); | 1240 | if (clicked === "copy-path") { |
| 879 | await handleNewThread(projectId, { | 1241 | copyPathToClipboard(project.cwd, { path: project.cwd }); |
| 880 | envMode: appSettings.defaultThreadEnvMode, | 1242 | return; |
| 881 | }).catch(() => undefined); | 1243 | } |
| 882 | } catch (error) { | 1244 | if (clicked !== "delete") return; |
| 883 | const description = | 1245 | |
| 884 | error instanceof Error ? error.message : "An error occurred while adding the project."; | 1246 | if (projectThreads.length > 0) { |
| 885 | setIsAddingProject(false); | 1247 | toastManager.add({ |
| 886 | if (shouldBrowseForProjectImmediately) { | 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 | toastManager.add({ | 1279 | toastManager.add({ |
| 888 | type: "error", | 1280 | type: "error", |
| 889 | title: "Failed to add project", | 1281 | title: `Failed to remove "${project.name}"`, |
| 890 | description, | 1282 | description: message, |
| 891 | }); | 1283 | }); |
| 892 | } else { | ||
| 893 | setAddProjectError(description); | ||
| 894 | } | 1284 | } |
| 895 | return; | 1285 | })(); |
| 896 | } | ||
| 897 | finishAddingProject(); | ||
| 898 | }, | 1286 | }, |
| 899 | [ | 1287 | [ |
| 900 | focusMostRecentThreadForProject, | 1288 | clearComposerDraftForThread, |
| 901 | handleNewThread, | 1289 | clearProjectDraftThreadId, |
| 902 | isAddingProject, | 1290 | copyPathToClipboard, |
| 903 | projects, | 1291 | getDraftThreadByProjectRef, |
| 904 | shouldBrowseForProjectImmediately, | 1292 | project.cwd, |
| 905 | appSettings.defaultThreadEnvMode, | 1293 | project.environmentId, |
| 1294 | project.id, | ||
| 1295 | project.name, | ||
| 1296 | projectThreads.length, | ||
| 1297 | suppressProjectClickForContextMenuRef, | ||
| 906 | ], | 1298 | ], |
| 907 | ); | 1299 | ); |
| 908 | 1300 | ||
| 909 | const handleAddProject = () => { | 1301 | const navigateToThread = useCallback( |
| 910 | void addProjectFromPath(newCwd); | 1302 | (threadRef: ScopedThreadRef) => { |
| 911 | }; | 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 () => { | 1327 | if (isModClick) { |
| 916 | const api = readNativeApi(); | 1328 | event.preventDefault(); |
| 917 | if (!api || isPickingFolder) return; | 1329 | toggleThreadSelection(threadKey); |
| 918 | setIsPickingFolder(true); | 1330 | return; |
| 919 | let pickedPath: string | null = null; | 1331 | } |
| 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 | }, []); | ||
| 946 | 1332 | ||
| 947 | const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => { | 1333 | if (isShiftClick) { |
| 948 | if (element && renamingInputRef.current !== element) { | 1334 | event.preventDefault(); |
| 949 | renamingInputRef.current = element; | 1335 | rangeSelectTo(threadKey, orderedProjectThreadKeys); |
| 950 | element.focus(); | 1336 | return; |
| 951 | element.select(); | 1337 | } |
| 952 | return; | ||
| 953 | } | ||
| 954 | if (element === null && renamingInputRef.current !== null) { | ||
| 955 | renamingInputRef.current = null; | ||
| 956 | } | ||
| 957 | }, []); | ||
| 958 | 1338 | ||
| 959 | const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []); | 1339 | if (currentSelectionCount > 0) { |
| 960 | const markRenameCommitted = useCallback(() => { | 1340 | clearSelection(); |
| 961 | renamingCommittedRef.current = true; | 1341 | } |
| 962 | }, []); | 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(() => { | 1351 | const handleMultiSelectContextMenu = useCallback( |
| 965 | setRenamingProjectId(null); | 1352 | async (position: { x: number; y: number }) => { |
| 966 | projectRenamingInputRef.current = null; | 1353 | const api = readLocalApi(); |
| 967 | }, []); | 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( | 1359 | const clicked = await api.contextMenu.show( |
| 970 | async (threadId: ThreadId, newTitle: string, originalTitle: string) => { | 1360 | [ |
| 971 | const finishRename = () => { | 1361 | { id: "mark-unread", label: `Mark unread (${count})` }, |
| 972 | setRenamingThreadId((current) => { | 1362 | { id: "delete", label: `Delete (${count})`, destructive: true }, |
| 973 | if (current !== threadId) return current; | 1363 | ], |
| 974 | renamingInputRef.current = null; | 1364 | position, |
| 975 | return null; | 1365 | ); |
| 976 | }); | ||
| 977 | }; | ||
| 978 | 1366 | ||
| 979 | const trimmed = newTitle.trim(); | 1367 | if (clicked === "mark-unread") { |
| 980 | if (trimmed.length === 0) { | 1368 | for (const threadKey of threadKeys) { |
| 981 | toastManager.add({ | 1369 | const thread = sidebarThreadByKey.get(threadKey); |
| 982 | type: "warning", | 1370 | markThreadUnread(threadKey, thread?.latestTurn?.completedAt); |
| 983 | title: "Thread title cannot be empty", | 1371 | } |
| 984 | }); | 1372 | clearSelection(); |
| 985 | finishRename(); | ||
| 986 | return; | 1373 | return; |
| 987 | } | 1374 | } |
| 988 | if (trimmed === originalTitle) { | 1375 | |
| 989 | finishRename(); | 1376 | if (clicked !== "delete") return; |
| 990 | 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(); | 1387 | |
| 993 | if (!api) { | 1388 | const deletedThreadKeys = new Set(threadKeys); |
| 994 | finishRename(); | 1389 | for (const threadKey of threadKeys) { |
| 995 | return; | 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 | try { | 1462 | try { |
| 998 | await api.orchestration.dispatchCommand({ | 1463 | await archiveThread(threadRef); |
| 999 | type: "thread.meta.update", | ||
| 1000 | commandId: newCommandId(), | ||
| 1001 | threadId, | ||
| 1002 | title: trimmed, | ||
| 1003 | }); | ||
| 1004 | } catch (error) { | 1464 | } catch (error) { |
| 1005 | toastManager.add({ | 1465 | toastManager.add({ |
| 1006 | type: "error", | 1466 | type: "error", |
| 1007 | title: "Failed to rename thread", | 1467 | title: "Failed to archive thread", |
| 1008 | description: error instanceof Error ? error.message : "An error occurred.", | 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( | 1475 | const cancelRename = useCallback(() => { |
| 1017 | async (projectId: ProjectId, newTitle: string, originalTitle: string) => { | 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 | const finishRename = () => { | 1483 | const finishRename = () => { |
| 1019 | setRenamingProjectId((current) => { | 1484 | setRenamingThreadKey((current) => { |
| 1020 | if (current !== projectId) return current; | 1485 | if (current !== threadKey) return current; |
| 1021 | projectRenamingInputRef.current = null; | 1486 | renamingInputRef.current = null; |
| 1022 | return null; | 1487 | return null; |
| 1023 | }); | 1488 | }); |
| 1024 | }; | 1489 | }; |
| ... | @@ -1027,7 +1492,7 @@ export default function Sidebar() { | ... | @@ -1027,7 +1492,7 @@ export default function Sidebar() { |
| 1027 | if (trimmed.length === 0) { | 1492 | if (trimmed.length === 0) { |
| 1028 | toastManager.add({ | 1493 | toastManager.add({ |
| 1029 | type: "warning", | 1494 | type: "warning", |
| 1030 | title: "Project title cannot be empty", | 1495 | title: "Thread title cannot be empty", |
| 1031 | }); | 1496 | }); |
| 1032 | finishRename(); | 1497 | finishRename(); |
| 1033 | return; | 1498 | return; |
| ... | @@ -1036,22 +1501,22 @@ export default function Sidebar() { | ... | @@ -1036,22 +1501,22 @@ export default function Sidebar() { |
| 1036 | finishRename(); | 1501 | finishRename(); |
| 1037 | return; | 1502 | return; |
| 1038 | } | 1503 | } |
| 1039 | const api = readNativeApi(); | 1504 | const api = readEnvironmentApi(threadRef.environmentId); |
| 1040 | if (!api) { | 1505 | if (!api) { |
| 1041 | finishRename(); | 1506 | finishRename(); |
| 1042 | return; | 1507 | return; |
| 1043 | } | 1508 | } |
| 1044 | try { | 1509 | try { |
| 1045 | await api.orchestration.dispatchCommand({ | 1510 | await api.orchestration.dispatchCommand({ |
| 1046 | type: "project.meta.update", | 1511 | type: "thread.meta.update", |
| 1047 | commandId: newCommandId(), | 1512 | commandId: newCommandId(), |
| 1048 | projectId, | 1513 | threadId: threadRef.threadId, |
| 1049 | title: trimmed, | 1514 | title: trimmed, |
| 1050 | }); | 1515 | }); |
| 1051 | } catch (error) { | 1516 | } catch (error) { |
| 1052 | toastManager.add({ | 1517 | toastManager.add({ |
| 1053 | type: "error", | 1518 | type: "error", |
| 1054 | title: "Failed to rename project", | 1519 | title: "Failed to rename thread", |
| 1055 | description: error instanceof Error ? error.message : "An error occurred.", | 1520 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1056 | }); | 1521 | }); |
| 1057 | } | 1522 | } |
| ... | @@ -1060,50 +1525,19 @@ export default function Sidebar() { | ... | @@ -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 | const handleThreadContextMenu = useCallback( | 1528 | const handleThreadContextMenu = useCallback( |
| 1100 | async (threadId: ThreadId, position: { x: number; y: number }) => { | 1529 | async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { |
| 1101 | const api = readNativeApi(); | 1530 | const api = readLocalApi(); |
| 1102 | if (!api) return; | 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 | if (!thread) return; | 1539 | if (!thread) return; |
| 1105 | const threadWorkspacePath = | 1540 | const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null; |
| 1106 | thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; | ||
| 1107 | const clicked = await api.contextMenu.show( | 1541 | const clicked = await api.contextMenu.show( |
| 1108 | [ | 1542 | [ |
| 1109 | { id: "rename", label: "Rename thread" }, | 1543 | { id: "rename", label: "Rename thread" }, |
| ... | @@ -1116,16 +1550,14 @@ export default function Sidebar() { | ... | @@ -1116,16 +1550,14 @@ export default function Sidebar() { |
| 1116 | ); | 1550 | ); |
| 1117 | 1551 | ||
| 1118 | if (clicked === "rename") { | 1552 | if (clicked === "rename") { |
| 1119 | setRenamingProjectId(null); | 1553 | setRenamingThreadKey(threadKey); |
| 1120 | projectRenamingInputRef.current = null; | ||
| 1121 | setRenamingThreadId(threadId); | ||
| 1122 | setRenamingTitle(thread.title); | 1554 | setRenamingTitle(thread.title); |
| 1123 | renamingCommittedRef.current = false; | 1555 | renamingCommittedRef.current = false; |
| 1124 | return; | 1556 | return; |
| 1125 | } | 1557 | } |
| 1126 | 1558 | ||
| 1127 | if (clicked === "mark-unread") { | 1559 | if (clicked === "mark-unread") { |
| 1128 | markThreadUnread(threadId, thread.latestTurn?.completedAt); | 1560 | markThreadUnread(threadKey, thread.latestTurn?.completedAt); |
| 1129 | return; | 1561 | return; |
| 1130 | } | 1562 | } |
| 1131 | if (clicked === "copy-path") { | 1563 | if (clicked === "copy-path") { |
| ... | @@ -1141,11 +1573,11 @@ export default function Sidebar() { | ... | @@ -1141,11 +1573,11 @@ export default function Sidebar() { |
| 1141 | return; | 1573 | return; |
| 1142 | } | 1574 | } |
| 1143 | if (clicked === "copy-thread-id") { | 1575 | if (clicked === "copy-thread-id") { |
| 1144 | copyThreadIdToClipboard(threadId, { threadId }); | 1576 | copyThreadIdToClipboard(thread.id, { threadId: thread.id }); |
| 1145 | return; | 1577 | return; |
| 1146 | } | 1578 | } |
| 1147 | if (clicked !== "delete") return; | 1579 | if (clicked !== "delete") return; |
| 1148 | if (appSettings.confirmThreadDelete) { | 1580 | if (appSettingsConfirmThreadDelete) { |
| 1149 | const confirmed = await api.dialogs.confirm( | 1581 | const confirmed = await api.dialogs.confirm( |
| 1150 | [ | 1582 | [ |
| 1151 | `Delete thread "${thread.title}"?`, | 1583 | `Delete thread "${thread.title}"?`, |
| ... | @@ -1156,198 +1588,903 @@ export default function Sidebar() { | ... | @@ -1156,198 +1588,903 @@ export default function Sidebar() { |
| 1156 | return; | 1588 | return; |
| 1157 | } | 1589 | } |
| 1158 | } | 1590 | } |
| 1159 | await deleteThread(threadId); | 1591 | await deleteThread(threadRef); |
| 1160 | }, | 1592 | }, |
| 1161 | [ | 1593 | [ |
| 1162 | appSettings.confirmThreadDelete, | 1594 | appSettingsConfirmThreadDelete, |
| 1163 | copyPathToClipboard, | 1595 | copyPathToClipboard, |
| 1164 | copyThreadIdToClipboard, | 1596 | copyThreadIdToClipboard, |
| 1165 | deleteThread, | 1597 | deleteThread, |
| 1166 | markThreadUnread, | 1598 | markThreadUnread, |
| 1167 | projectCwdById, | 1599 | project.cwd, |
| 1168 | sidebarThreadsById, | 1600 | projectThreads, |
| 1169 | ], | 1601 | ], |
| 1170 | ); | 1602 | ); |
| 1171 | 1603 | ||
| 1172 | const handleMultiSelectContextMenu = useCallback( | 1604 | return ( |
| 1173 | async (position: { x: number; y: number }) => { | 1605 | <> |
| 1174 | const api = readNativeApi(); | 1606 | <div className="group/project-header relative"> |
| 1175 | if (!api) return; | 1607 | <SidebarMenuButton |
| 1176 | const ids = [...selectedThreadIds]; | 1608 | ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined} |
| 1177 | if (ids.length === 0) return; | 1609 | size="sm" |
| 1178 | const count = ids.length; | 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 ${ |
| 1179 | 1611 | isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" | |
| 1180 | const clicked = await api.contextMenu.show( | 1612 | }`} |
| 1181 | [ | 1613 | {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})} |
| 1182 | { id: "mark-unread", label: `Mark unread (${count})` }, | 1614 | {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})} |
| 1183 | { id: "delete", label: `Delete (${count})`, destructive: true }, | 1615 | onPointerDownCapture={handleProjectButtonPointerDownCapture} |
| 1184 | ], | 1616 | onClick={handleProjectButtonClick} |
| 1185 | position, | 1617 | onKeyDown={handleProjectButtonKeyDown} |
| 1186 | ); | 1618 | onContextMenu={handleProjectButtonContextMenu} |
| 1187 | 1619 | > | |
| 1188 | if (clicked === "mark-unread") { | 1620 | {!projectExpanded && projectStatus ? ( |
| 1189 | for (const id of ids) { | 1621 | <span |
| 1190 | const thread = sidebarThreadsById[id]; | 1622 | aria-hidden="true" |
| 1191 | markThreadUnread(id, thread?.latestTurn?.completedAt); | 1623 | title={projectStatus.label} |
| 1192 | } | 1624 | className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`} |
| 1193 | clearSelection(); | 1625 | > |
| 1194 | return; | 1626 | <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0"> |
| 1195 | } | 1627 | <span |
| 1196 | 1628 | className={`size-[9px] rounded-full ${projectStatus.dotClass} ${ | |
| 1197 | if (clicked !== "delete") return; | 1629 | projectStatus.pulse ? "animate-pulse" : "" |
| 1198 | 1630 | }`} | |
| 1199 | if (appSettings.confirmThreadDelete) { | 1631 | /> |
| 1200 | const confirmed = await api.dialogs.confirm( | 1632 | </span> |
| 1201 | [ | 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" /> |
| 1202 | `Delete ${count} thread${count === 1 ? "" : "s"}?`, | 1634 | </span> |
| 1203 | "This permanently clears conversation history for these threads.", | 1635 | ) : ( |
| 1204 | ].join("\n"), | 1636 | <ChevronRightIcon |
| 1205 | ); | 1637 | className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${ |
| 1206 | if (!confirmed) return; | 1638 | projectExpanded ? "rotate-90" : "" |
| 1207 | } | 1639 | }`} |
| 1208 | 1640 | /> | |
| 1209 | const deletedIds = new Set<ThreadId>(ids); | 1641 | )} |
| 1210 | for (const id of ids) { | 1642 | <ProjectFavicon cwd={project.cwd} /> |
| 1211 | await deleteThread(id, { deletedThreadIds: deletedIds }); | 1643 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 1212 | } | 1644 | {project.name} |
| 1213 | removeFromSelection(ids); | 1645 | </span> |
| 1214 | }, | 1646 | </SidebarMenuButton> |
| 1215 | [ | 1647 | <Tooltip> |
| 1216 | appSettings.confirmThreadDelete, | 1648 | <TooltipTrigger |
| 1217 | clearSelection, | 1649 | render={ |
| 1218 | deleteThread, | 1650 | <SidebarMenuAction |
| 1219 | markThreadUnread, | 1651 | render={ |
| 1220 | removeFromSelection, | 1652 | <button |
| 1221 | selectedThreadIds, | 1653 | type="button" |
| 1222 | sidebarThreadsById, | 1654 | aria-label={`Create new thread in ${project.name}`} |
| 1223 | ], | 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( | 1711 | const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { |
| 1227 | (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => { | 1712 | return ( |
| 1228 | const isMac = isMacPlatform(navigator.platform); | 1713 | <SidebarMenuItem className="rounded-md"> |
| 1229 | const isModClick = isMac ? event.metaKey : event.ctrlKey; | 1714 | <SidebarProjectItem {...props} /> |
| 1230 | const isShiftClick = event.shiftKey; | 1715 | </SidebarMenuItem> |
| 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 | ], | ||
| 1262 | ); | 1716 | ); |
| 1717 | }); | ||
| 1263 | 1718 | ||
| 1264 | const navigateToThread = useCallback( | 1719 | function T3Wordmark() { |
| 1265 | (threadId: ThreadId) => { | 1720 | return ( |
| 1266 | if (selectedThreadIds.size > 0) { | 1721 | <svg |
| 1267 | clearSelection(); | 1722 | aria-label="T3" |
| 1268 | } | 1723 | className="h-2.5 w-auto shrink-0 text-foreground" |
| 1269 | setSelectionAnchor(threadId); | 1724 | viewBox="15.5309 37 94.3941 56.96" |
| 1270 | void navigate({ | 1725 | xmlns="http://www.w3.org/2000/svg" |
| 1271 | to: "/$threadId", | 1726 | > |
| 1272 | params: { threadId }, | 1727 | <path |
| 1273 | }); | 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" |
| 1274 | }, | 1729 | fill="currentColor" |
| 1275 | [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], | 1730 | /> |
| 1731 | </svg> | ||
| 1276 | ); | 1732 | ); |
| 1733 | } | ||
| 1277 | 1734 | ||
| 1278 | const handleProjectContextMenu = useCallback( | 1735 | type SortableProjectHandleProps = Pick< |
| 1279 | async (projectId: ProjectId, position: { x: number; y: number }) => { | 1736 | ReturnType<typeof useSortable>, |
| 1280 | const api = readNativeApi(); | 1737 | "attributes" | "listeners" | "setActivatorNodeRef" |
| 1281 | if (!api) return; | 1738 | >; |
| 1282 | const project = projects.find((entry) => entry.id === projectId); | ||
| 1283 | if (!project) return; | ||
| 1284 | 1739 | ||
| 1285 | const clicked = await api.contextMenu.show( | 1740 | function ProjectSortMenu({ |
| 1286 | [ | 1741 | projectSortOrder, |
| 1287 | { id: "rename", label: "Rename project" }, | 1742 | threadSortOrder, |
| 1288 | { id: "copy-path", label: "Copy Project Path" }, | 1743 | onProjectSortOrderChange, |
| 1289 | { id: "delete", label: "Remove project", destructive: true }, | 1744 | onThreadSortOrderChange, |
| 1290 | ], | 1745 | }: { |
| 1291 | position, | 1746 | projectSortOrder: SidebarProjectSortOrder; |
| 1292 | ); | 1747 | threadSortOrder: SidebarThreadSortOrder; |
| 1293 | if (clicked === "rename") { | 1748 | onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; |
| 1294 | setRenamingThreadId(null); | 1749 | onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; |
| 1295 | renamingInputRef.current = null; | 1750 | }) { |
| 1296 | setRenamingProjectId(projectId); | 1751 | return ( |
| 1297 | setRenamingProjectTitle(project.name); | 1752 | <Menu> |
| 1298 | projectRenamingCommittedRef.current = false; | 1753 | <Tooltip> |
| 1299 | return; | 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") { | 2036 | }, |
| 1302 | copyPathToClipboard(project.cwd, { path: project.cwd }); | 2037 | [handleAddProject, setAddProjectError, setAddingProject], |
| 1303 | return; | 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] ?? []; | 2359 | void navigate({ |
| 1308 | if (projectThreadIds.length > 0) { | 2360 | to: "/$environmentId/$threadId", |
| 1309 | toastManager.add({ | 2361 | params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), |
| 1310 | type: "warning", | 2362 | }); |
| 1311 | title: "Project is not empty", | 2363 | }, |
| 1312 | description: "Delete all threads in this project before removing it.", | 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 | return; | 2389 | return; |
| 1315 | } | 2390 | } |
| 1316 | 2391 | ||
| 1317 | const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); | 2392 | const projectId = newProjectId(); |
| 1318 | if (!confirmed) return; | 2393 | const createdAt = new Date().toISOString(); |
| 1319 | 2394 | const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; | |
| 1320 | try { | 2395 | try { |
| 1321 | const projectDraftThread = getDraftThreadByProjectId(projectId); | ||
| 1322 | if (projectDraftThread) { | ||
| 1323 | clearComposerDraftForThread(projectDraftThread.threadId); | ||
| 1324 | } | ||
| 1325 | clearProjectDraftThreadId(projectId); | ||
| 1326 | await api.orchestration.dispatchCommand({ | 2396 | await api.orchestration.dispatchCommand({ |
| 1327 | type: "project.delete", | 2397 | type: "project.create", |
| 1328 | commandId: newCommandId(), | 2398 | commandId: newCommandId(), |
| 1329 | projectId, | 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 | } catch (error) { | 2413 | } catch (error) { |
| 1332 | const message = error instanceof Error ? error.message : "Unknown error removing project."; | 2414 | const description = |
| 1333 | console.error("Failed to remove project", { projectId, error }); | 2415 | error instanceof Error ? error.message : "An error occurred while adding the project."; |
| 1334 | toastManager.add({ | 2416 | setIsAddingProject(false); |
| 1335 | type: "error", | 2417 | if (shouldBrowseForProjectImmediately) { |
| 1336 | title: `Failed to remove "${project.name}"`, | 2418 | toastManager.add({ |
| 1337 | description: message, | 2419 | type: "error", |
| 1338 | }); | 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, | 2431 | focusMostRecentThreadForProject, |
| 1343 | clearProjectDraftThreadId, | 2432 | activeEnvironmentId, |
| 1344 | copyPathToClipboard, | 2433 | handleNewThread, |
| 1345 | getDraftThreadByProjectId, | 2434 | isAddingProject, |
| 1346 | projects, | 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 | const projectDnDSensors = useSensors( | 2488 | const projectDnDSensors = useSensors( |
| 1352 | useSensor(PointerSensor, { | 2489 | useSensor(PointerSensor, { |
| 1353 | activationConstraint: { distance: 6 }, | 2490 | activationConstraint: { distance: 6 }, |
| ... | @@ -1371,10 +2508,10 @@ export default function Sidebar() { | ... | @@ -1371,10 +2508,10 @@ export default function Sidebar() { |
| 1371 | dragInProgressRef.current = false; | 2508 | dragInProgressRef.current = false; |
| 1372 | const { active, over } = event; | 2509 | const { active, over } = event; |
| 1373 | if (!over || active.id === over.id) return; | 2510 | if (!over || active.id === over.id) return; |
| 1374 | const activeProject = sidebarProjects.find((project) => project.id === active.id); | 2511 | const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); |
| 1375 | const overProject = sidebarProjects.find((project) => project.id === over.id); | 2512 | const overProject = sidebarProjects.find((project) => project.projectKey === over.id); |
| 1376 | if (!activeProject || !overProject) return; | 2513 | if (!activeProject || !overProject) return; |
| 1377 | reorderProjects(activeProject.id, overProject.id); | 2514 | reorderProjects(activeProject.projectKey, overProject.projectKey); |
| 1378 | }, | 2515 | }, |
| 1379 | [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], | 2516 | [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], |
| 1380 | ); | 2517 | ); |
| ... | @@ -1412,148 +2549,117 @@ export default function Sidebar() { | ... | @@ -1412,148 +2549,117 @@ export default function Sidebar() { |
| 1412 | animatedThreadListsRef.current.add(node); | 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 | const visibleThreads = useMemo( | 2552 | const visibleThreads = useMemo( |
| 1435 | () => sidebarThreads.filter((thread) => thread.archivedAt === null), | 2553 | () => sidebarThreads.filter((thread) => thread.archivedAt === null), |
| 1436 | [sidebarThreads], | 2554 | [sidebarThreads], |
| 1437 | ); | 2555 | ); |
| 1438 | const sortedProjects = useMemo( | 2556 | const sortedProjects = useMemo(() => { |
| 1439 | () => | 2557 | const sortableProjects = sidebarProjects.map((project) => ({ |
| 1440 | sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), | 2558 | ...project, |
| 1441 | [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], | 2559 | id: project.projectKey, |
| 1442 | ); | 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 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; | 2576 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; |
| 1444 | const renderedProjects = useMemo( | 2577 | const visibleSidebarThreadKeys = useMemo( |
| 1445 | () => | 2578 | () => |
| 1446 | sortedProjects.map((project) => { | 2579 | sortedProjects.flatMap((project) => { |
| 1447 | const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) => | ||
| 1448 | resolveThreadStatusPill({ | ||
| 1449 | thread: { | ||
| 1450 | ...thread, | ||
| 1451 | lastVisitedAt: threadLastVisitedAtById[thread.id], | ||
| 1452 | }, | ||
| 1453 | }); | ||
| 1454 | const projectThreads = sortThreadsForSidebar( | 2580 | const projectThreads = sortThreadsForSidebar( |
| 1455 | (threadIdsByProjectId[project.id] ?? []) | 2581 | (threadsByProjectKey.get(project.projectKey) ?? []).filter( |
| 1456 | .map((threadId) => sidebarThreadsById[threadId]) | 2582 | (thread) => thread.archivedAt === null, |
| 1457 | .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined) | 2583 | ), |
| 1458 | .filter((thread) => thread.archivedAt === null), | ||
| 1459 | appSettings.sidebarThreadSortOrder, | 2584 | appSettings.sidebarThreadSortOrder, |
| 1460 | ); | 2585 | ); |
| 1461 | const projectStatus = resolveProjectStatusIndicator( | 2586 | const projectExpanded = projectExpandedById[project.projectKey] ?? true; |
| 1462 | projectThreads.map((thread) => resolveProjectThreadStatus(thread)), | 2587 | const activeThreadKey = routeThreadKey ?? undefined; |
| 1463 | ); | ||
| 1464 | const activeThreadId = routeThreadId ?? undefined; | ||
| 1465 | const isThreadListExpanded = expandedThreadListsByProject.has(project.id); | ||
| 1466 | const pinnedCollapsedThread = | 2588 | const pinnedCollapsedThread = |
| 1467 | !project.expanded && activeThreadId | 2589 | !projectExpanded && activeThreadKey |
| 1468 | ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) | 2590 | ? (projectThreads.find( |
| 2591 | (thread) => | ||
| 2592 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === | ||
| 2593 | activeThreadKey, | ||
| 2594 | ) ?? null) | ||
| 1469 | : null; | 2595 | : null; |
| 1470 | const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; | 2596 | const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; |
| 1471 | const { | 2597 | if (!shouldShowThreadPanel) { |
| 1472 | hasHiddenThreads, | 2598 | return []; |
| 1473 | hiddenThreads, | 2599 | } |
| 1474 | visibleThreads: visibleProjectThreads, | 2600 | const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); |
| 1475 | } = getVisibleThreadsForProject({ | 2601 | const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; |
| 1476 | threads: projectThreads, | 2602 | const previewThreads = |
| 1477 | activeThreadId, | 2603 | isThreadListExpanded || !hasOverflowingThreads |
| 1478 | isThreadListExpanded, | 2604 | ? projectThreads |
| 1479 | previewLimit: THREAD_PREVIEW_LIMIT, | 2605 | : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); |
| 1480 | }); | 2606 | const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; |
| 1481 | const hiddenThreadStatus = resolveProjectStatusIndicator( | 2607 | return renderedThreads.map((thread) => |
| 1482 | hiddenThreads.map((thread) => resolveProjectThreadStatus(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 | appSettings.sidebarThreadSortOrder, | 2612 | appSettings.sidebarThreadSortOrder, |
| 1504 | expandedThreadListsByProject, | 2613 | expandedThreadListsByProject, |
| 1505 | routeThreadId, | 2614 | projectExpandedById, |
| 2615 | routeThreadKey, | ||
| 1506 | sortedProjects, | 2616 | sortedProjects, |
| 1507 | sidebarThreadsById, | 2617 | threadsByProjectKey, |
| 1508 | threadIdsByProjectId, | ||
| 1509 | threadLastVisitedAtById, | ||
| 1510 | ], | 2618 | ], |
| 1511 | ); | 2619 | ); |
| 1512 | const visibleSidebarThreadIds = useMemo( | 2620 | const threadJumpCommandByKey = useMemo(() => { |
| 1513 | () => getVisibleSidebarThreadIds(renderedProjects), | 2621 | const mapping = new Map<string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>(); |
| 1514 | [renderedProjects], | 2622 | for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { |
| 1515 | ); | ||
| 1516 | const threadJumpCommandById = useMemo(() => { | ||
| 1517 | const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>(); | ||
| 1518 | for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { | ||
| 1519 | const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); | 2623 | const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); |
| 1520 | if (!jumpCommand) { | 2624 | if (!jumpCommand) { |
| 1521 | return mapping; | 2625 | return mapping; |
| 1522 | } | 2626 | } |
| 1523 | mapping.set(threadId, jumpCommand); | 2627 | mapping.set(threadKey, jumpCommand); |
| 1524 | } | 2628 | } |
| 1525 | 2629 | ||
| 1526 | return mapping; | 2630 | return mapping; |
| 1527 | }, [visibleSidebarThreadIds]); | 2631 | }, [visibleSidebarThreadKeys]); |
| 1528 | const threadJumpThreadIds = useMemo( | 2632 | const threadJumpThreadKeys = useMemo( |
| 1529 | () => [...threadJumpCommandById.keys()], | 2633 | () => [...threadJumpCommandByKey.keys()], |
| 1530 | [threadJumpCommandById], | 2634 | [threadJumpCommandByKey], |
| 1531 | ); | 2635 | ); |
| 1532 | const threadJumpLabelById = useMemo(() => { | 2636 | const [threadJumpLabelByKey, setThreadJumpLabelByKey] = |
| 1533 | const mapping = new Map<ThreadId, string>(); | 2637 | useState<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS); |
| 1534 | for (const [threadId, command] of threadJumpCommandById) { | 2638 | const visibleThreadJumpLabelByKey = showThreadJumpHints |
| 1535 | const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); | 2639 | ? threadJumpLabelByKey |
| 1536 | if (label) { | 2640 | : EMPTY_THREAD_JUMP_LABELS; |
| 1537 | mapping.set(threadId, label); | 2641 | const orderedSidebarThreadKeys = visibleSidebarThreadKeys; |
| 1538 | } | ||
| 1539 | } | ||
| 1540 | return mapping; | ||
| 1541 | }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); | ||
| 1542 | const orderedSidebarThreadIds = visibleSidebarThreadIds; | ||
| 1543 | 2642 | ||
| 1544 | useEffect(() => { | 2643 | useEffect(() => { |
| 1545 | const getShortcutContext = () => ({ | ||
| 1546 | terminalFocus: isTerminalFocused(), | ||
| 1547 | terminalOpen: routeTerminalOpen, | ||
| 1548 | }); | ||
| 1549 | |||
| 1550 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { | 2644 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { |
| 1551 | updateThreadJumpHintsVisibility( | 2645 | const shortcutContext = getCurrentSidebarShortcutContext(); |
| 1552 | shouldShowThreadJumpHints(event, keybindings, { | 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 | platform, | 2656 | platform, |
| 1554 | context: getShortcutContext(), | 2657 | terminalOpen: shortcutContext.terminalOpen, |
| 1555 | }), | 2658 | threadJumpCommandByKey, |
| 1556 | ); | 2659 | }); |
| 2660 | return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; | ||
| 2661 | }); | ||
| 2662 | updateThreadJumpHintsVisibility(shouldShowHints); | ||
| 1557 | 2663 | ||
| 1558 | if (event.defaultPrevented || event.repeat) { | 2664 | if (event.defaultPrevented || event.repeat) { |
| 1559 | return; | 2665 | return; |
| ... | @@ -1561,22 +2667,26 @@ export default function Sidebar() { | ... | @@ -1561,22 +2667,26 @@ export default function Sidebar() { |
| 1561 | 2667 | ||
| 1562 | const command = resolveShortcutCommand(event, keybindings, { | 2668 | const command = resolveShortcutCommand(event, keybindings, { |
| 1563 | platform, | 2669 | platform, |
| 1564 | context: getShortcutContext(), | 2670 | context: shortcutContext, |
| 1565 | }); | 2671 | }); |
| 1566 | const traversalDirection = threadTraversalDirectionFromCommand(command); | 2672 | const traversalDirection = threadTraversalDirectionFromCommand(command); |
| 1567 | if (traversalDirection !== null) { | 2673 | if (traversalDirection !== null) { |
| 1568 | const targetThreadId = resolveAdjacentThreadId({ | 2674 | const targetThreadKey = resolveAdjacentThreadId({ |
| 1569 | threadIds: orderedSidebarThreadIds, | 2675 | threadIds: orderedSidebarThreadKeys, |
| 1570 | currentThreadId: routeThreadId, | 2676 | currentThreadId: routeThreadKey, |
| 1571 | direction: traversalDirection, | 2677 | direction: traversalDirection, |
| 1572 | }); | 2678 | }); |
| 1573 | if (!targetThreadId) { | 2679 | if (!targetThreadKey) { |
| 2680 | return; | ||
| 2681 | } | ||
| 2682 | const targetThread = sidebarThreadByKey.get(targetThreadKey); | ||
| 2683 | if (!targetThread) { | ||
| 1574 | return; | 2684 | return; |
| 1575 | } | 2685 | } |
| 1576 | 2686 | ||
| 1577 | event.preventDefault(); | 2687 | event.preventDefault(); |
| 1578 | event.stopPropagation(); | 2688 | event.stopPropagation(); |
| 1579 | navigateToThread(targetThreadId); | 2689 | navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); |
| 1580 | return; | 2690 | return; |
| 1581 | } | 2691 | } |
| 1582 | 2692 | ||
| ... | @@ -1585,26 +2695,45 @@ export default function Sidebar() { | ... | @@ -1585,26 +2695,45 @@ export default function Sidebar() { |
| 1585 | return; | 2695 | return; |
| 1586 | } | 2696 | } |
| 1587 | 2697 | ||
| 1588 | const targetThreadId = threadJumpThreadIds[jumpIndex]; | 2698 | const targetThreadKey = threadJumpThreadKeys[jumpIndex]; |
| 1589 | if (!targetThreadId) { | 2699 | if (!targetThreadKey) { |
| 2700 | return; | ||
| 2701 | } | ||
| 2702 | const targetThread = sidebarThreadByKey.get(targetThreadKey); | ||
| 2703 | if (!targetThread) { | ||
| 1590 | return; | 2704 | return; |
| 1591 | } | 2705 | } |
| 1592 | 2706 | ||
| 1593 | event.preventDefault(); | 2707 | event.preventDefault(); |
| 1594 | event.stopPropagation(); | 2708 | event.stopPropagation(); |
| 1595 | navigateToThread(targetThreadId); | 2709 | navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); |
| 1596 | }; | 2710 | }; |
| 1597 | 2711 | ||
| 1598 | const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { | 2712 | const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { |
| 1599 | updateThreadJumpHintsVisibility( | 2713 | const shortcutContext = getCurrentSidebarShortcutContext(); |
| 1600 | shouldShowThreadJumpHints(event, keybindings, { | 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 | platform, | 2724 | platform, |
| 1602 | context: getShortcutContext(), | 2725 | terminalOpen: shortcutContext.terminalOpen, |
| 1603 | }), | 2726 | threadJumpCommandByKey, |
| 1604 | ); | 2727 | }); |
| 2728 | return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; | ||
| 2729 | }); | ||
| 2730 | updateThreadJumpHintsVisibility(shouldShowHints); | ||
| 1605 | }; | 2731 | }; |
| 1606 | 2732 | ||
| 1607 | const onWindowBlur = () => { | 2733 | const onWindowBlur = () => { |
| 2734 | setThreadJumpLabelByKey((current) => | ||
| 2735 | current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, | ||
| 2736 | ); | ||
| 1608 | updateThreadJumpHintsVisibility(false); | 2737 | updateThreadJumpHintsVisibility(false); |
| 1609 | }; | 2738 | }; |
| 1610 | 2739 | ||
| ... | @@ -1618,301 +2747,21 @@ export default function Sidebar() { | ... | @@ -1618,301 +2747,21 @@ export default function Sidebar() { |
| 1618 | window.removeEventListener("blur", onWindowBlur); | 2747 | window.removeEventListener("blur", onWindowBlur); |
| 1619 | }; | 2748 | }; |
| 1620 | }, [ | 2749 | }, [ |
| 2750 | getCurrentSidebarShortcutContext, | ||
| 1621 | keybindings, | 2751 | keybindings, |
| 1622 | navigateToThread, | 2752 | navigateToThread, |
| 1623 | orderedSidebarThreadIds, | 2753 | orderedSidebarThreadKeys, |
| 1624 | platform, | 2754 | platform, |
| 1625 | routeTerminalOpen, | 2755 | routeThreadKey, |
| 1626 | routeThreadId, | 2756 | sidebarThreadByKey, |
| 1627 | threadJumpThreadIds, | 2757 | threadJumpCommandByKey, |
| 2758 | threadJumpThreadKeys, | ||
| 1628 | updateThreadJumpHintsVisibility, | 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 | useEffect(() => { | 2762 | useEffect(() => { |
| 1914 | const onMouseDown = (event: globalThis.MouseEvent) => { | 2763 | const onMouseDown = (event: globalThis.MouseEvent) => { |
| 1915 | if (selectedThreadIds.size === 0) return; | 2764 | if (selectedThreadCount === 0) return; |
| 1916 | const target = event.target instanceof HTMLElement ? event.target : null; | 2765 | const target = event.target instanceof HTMLElement ? event.target : null; |
| 1917 | if (!shouldClearThreadSelectionOnMouseDown(target)) return; | 2766 | if (!shouldClearThreadSelectionOnMouseDown(target)) return; |
| 1918 | clearSelection(); | 2767 | clearSelection(); |
| ... | @@ -1922,7 +2771,7 @@ export default function Sidebar() { | ... | @@ -1922,7 +2771,7 @@ export default function Sidebar() { |
| 1922 | return () => { | 2771 | return () => { |
| 1923 | window.removeEventListener("mousedown", onMouseDown); | 2772 | window.removeEventListener("mousedown", onMouseDown); |
| 1924 | }; | 2773 | }; |
| 1925 | }, [clearSelection, selectedThreadIds.size]); | 2774 | }, [clearSelection, selectedThreadCount]); |
| 1926 | 2775 | ||
| 1927 | useEffect(() => { | 2776 | useEffect(() => { |
| 1928 | if (!isElectron) return; | 2777 | if (!isElectron) return; |
| ... | @@ -1967,10 +2816,6 @@ export default function Sidebar() { | ... | @@ -1967,10 +2816,6 @@ export default function Sidebar() { |
| 1967 | desktopUpdateState && showArm64IntelBuildWarning | 2816 | desktopUpdateState && showArm64IntelBuildWarning |
| 1968 | ? getArm64IntelBuildWarningDescription(desktopUpdateState) | 2817 | ? getArm64IntelBuildWarningDescription(desktopUpdateState) |
| 1969 | : null; | 2818 | : null; |
| 1970 | const newThreadShortcutLabel = | ||
| 1971 | shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? | ||
| 1972 | shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); | ||
| 1973 | |||
| 1974 | const handleDesktopUpdateButtonClick = useCallback(() => { | 2819 | const handleDesktopUpdateButtonClick = useCallback(() => { |
| 1975 | const bridge = window.desktopBridge; | 2820 | const bridge = window.desktopBridge; |
| 1976 | if (!bridge || !desktopUpdateState) return; | 2821 | if (!bridge || !desktopUpdateState) return; |
| ... | @@ -2033,246 +2878,82 @@ export default function Sidebar() { | ... | @@ -2033,246 +2878,82 @@ export default function Sidebar() { |
| 2033 | } | 2878 | } |
| 2034 | }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); | 2879 | }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); |
| 2035 | 2880 | ||
| 2036 | const expandThreadListForProject = useCallback((projectId: ProjectId) => { | 2881 | const expandThreadListForProject = useCallback((projectKey: string) => { |
| 2037 | setExpandedThreadListsByProject((current) => { | 2882 | setExpandedThreadListsByProject((current) => { |
| 2038 | if (current.has(projectId)) return current; | 2883 | if (current.has(projectKey)) return current; |
| 2039 | const next = new Set(current); | 2884 | const next = new Set(current); |
| 2040 | next.add(projectId); | 2885 | next.add(projectKey); |
| 2041 | return next; | 2886 | return next; |
| 2042 | }); | 2887 | }); |
| 2043 | }, []); | 2888 | }, []); |
| 2044 | 2889 | ||
| 2045 | const collapseThreadListForProject = useCallback((projectId: ProjectId) => { | 2890 | const collapseThreadListForProject = useCallback((projectKey: string) => { |
| 2046 | setExpandedThreadListsByProject((current) => { | 2891 | setExpandedThreadListsByProject((current) => { |
| 2047 | if (!current.has(projectId)) return current; | 2892 | if (!current.has(projectKey)) return current; |
| 2048 | const next = new Set(current); | 2893 | const next = new Set(current); |
| 2049 | next.delete(projectId); | 2894 | next.delete(projectKey); |
| 2050 | return next; | 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 | return ( | 2899 | return ( |
| 2083 | <> | 2900 | <> |
| 2084 | {isElectron ? ( | 2901 | <SidebarChromeHeader isElectron={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 | )} | ||
| 2093 | 2902 | ||
| 2094 | {isOnSettings ? ( | 2903 | {isOnSettings ? ( |
| 2095 | <SettingsSidebarNav pathname={pathname} /> | 2904 | <SettingsSidebarNav pathname={pathname} /> |
| 2096 | ) : ( | 2905 | ) : ( |
| 2097 | <> | 2906 | <> |
| 2098 | <SidebarContent className="gap-0"> | 2907 | <SidebarProjectsContent |
| 2099 | {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( | 2908 | showArm64IntelBuildWarning={showArm64IntelBuildWarning} |
| 2100 | <SidebarGroup className="px-2 pt-2 pb-0"> | 2909 | arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription} |
| 2101 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8"> | 2910 | desktopUpdateButtonAction={desktopUpdateButtonAction} |
| 2102 | <TriangleAlertIcon /> | 2911 | desktopUpdateButtonDisabled={desktopUpdateButtonDisabled} |
| 2103 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> | 2912 | handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} |
| 2104 | <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription> | 2913 | projectSortOrder={appSettings.sidebarProjectSortOrder} |
| 2105 | {desktopUpdateButtonAction !== "none" ? ( | 2914 | threadSortOrder={appSettings.sidebarThreadSortOrder} |
| 2106 | <AlertAction> | 2915 | updateSettings={updateSettings} |
| 2107 | <Button | 2916 | shouldShowProjectPathEntry={shouldShowProjectPathEntry} |
| 2108 | size="xs" | 2917 | handleStartAddProject={handleStartAddProject} |
| 2109 | variant="outline" | 2918 | isElectron={isElectron} |
| 2110 | disabled={desktopUpdateButtonDisabled} | 2919 | isPickingFolder={isPickingFolder} |
| 2111 | onClick={handleDesktopUpdateButtonClick} | 2920 | isAddingProject={isAddingProject} |
| 2112 | > | 2921 | handlePickFolder={handlePickFolder} |
| 2113 | {desktopUpdateButtonAction === "download" | 2922 | addProjectInputRef={addProjectInputRef} |
| 2114 | ? "Download ARM build" | 2923 | addProjectError={addProjectError} |
| 2115 | : "Install ARM build"} | 2924 | newCwd={newCwd} |
| 2116 | </Button> | 2925 | setNewCwd={setNewCwd} |
| 2117 | </AlertAction> | 2926 | setAddProjectError={setAddProjectError} |
| 2118 | ) : null} | 2927 | handleAddProject={handleAddProject} |
| 2119 | </Alert> | 2928 | setAddingProject={setAddingProject} |
| 2120 | </SidebarGroup> | 2929 | canAddProject={canAddProject} |
| 2121 | ) : null} | 2930 | isManualProjectSorting={isManualProjectSorting} |
| 2122 | <SidebarGroup className="px-2 py-2"> | 2931 | projectDnDSensors={projectDnDSensors} |
| 2123 | <div className="mb-1 flex items-center justify-between pl-2 pr-1.5"> | 2932 | projectCollisionDetection={projectCollisionDetection} |
| 2124 | <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> | 2933 | handleProjectDragStart={handleProjectDragStart} |
| 2125 | Projects | 2934 | handleProjectDragEnd={handleProjectDragEnd} |
| 2126 | </span> | 2935 | handleProjectDragCancel={handleProjectDragCancel} |
| 2127 | <div className="flex items-center gap-1"> | 2936 | handleNewThread={handleNewThread} |
| 2128 | <ProjectSortMenu | 2937 | archiveThread={archiveThread} |
| 2129 | projectSortOrder={appSettings.sidebarProjectSortOrder} | 2938 | deleteThread={deleteThread} |
| 2130 | threadSortOrder={appSettings.sidebarThreadSortOrder} | 2939 | sortedProjects={sortedProjects} |
| 2131 | onProjectSortOrderChange={(sortOrder) => { | 2940 | expandedThreadListsByProject={expandedThreadListsByProject} |
| 2132 | updateSettings({ sidebarProjectSortOrder: sortOrder }); | 2941 | activeRouteProjectKey={activeRouteProjectKey} |
| 2133 | }} | 2942 | routeThreadKey={routeThreadKey} |
| 2134 | onThreadSortOrderChange={(sortOrder) => { | 2943 | newThreadShortcutLabel={newThreadShortcutLabel} |
| 2135 | updateSettings({ sidebarThreadSortOrder: sortOrder }); | 2944 | threadJumpLabelByKey={visibleThreadJumpLabelByKey} |
| 2136 | }} | 2945 | attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} |
| 2137 | /> | 2946 | expandThreadListForProject={expandThreadListForProject} |
| 2138 | <Tooltip> | 2947 | collapseThreadListForProject={collapseThreadListForProject} |
| 2139 | <TooltipTrigger | 2948 | dragInProgressRef={dragInProgressRef} |
| 2140 | render={ | 2949 | suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} |
| 2141 | <button | 2950 | suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} |
| 2142 | type="button" | 2951 | attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} |
| 2143 | aria-label={ | 2952 | projectsLength={projects.length} |
| 2144 | shouldShowProjectPathEntry ? "Cancel add project" : "Add project" | 2953 | /> |
| 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> | ||
| 2259 | 2954 | ||
| 2260 | <SidebarSeparator /> | 2955 | <SidebarSeparator /> |
| 2261 | <SidebarFooter className="p-2"> | 2956 | <SidebarChromeFooter /> |
| 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> | ||
| 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,6 +1,7 @@ |
| 1 | import { FitAddon } from "@xterm/addon-fit"; | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; | 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; |
| 3 | import { | 3 | import { |
| 4 | type ScopedThreadRef, | ||
| 4 | type TerminalEvent, | 5 | type TerminalEvent, |
| 5 | type TerminalSessionSnapshot, | 6 | type TerminalSessionSnapshot, |
| 6 | type ThreadId, | 7 | type ThreadId, |
| ... | @@ -31,7 +32,8 @@ import { | ... | @@ -31,7 +32,8 @@ import { |
| 31 | MAX_TERMINALS_PER_GROUP, | 32 | MAX_TERMINALS_PER_GROUP, |
| 32 | type ThreadTerminalGroup, | 33 | type ThreadTerminalGroup, |
| 33 | } from "../types"; | 34 | } from "../types"; |
| 34 | import { readNativeApi } from "~/nativeApi"; | 35 | import { readEnvironmentApi } from "~/environmentApi"; |
| 36 | import { readLocalApi } from "~/localApi"; | ||
| 35 | import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; | 37 | import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; |
| 36 | 38 | ||
| 37 | const MIN_DRAWER_HEIGHT = 180; | 39 | const MIN_DRAWER_HEIGHT = 180; |
| ... | @@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp( | ... | @@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp( |
| 208 | } | 210 | } |
| 209 | 211 | ||
| 210 | interface TerminalViewportProps { | 212 | interface TerminalViewportProps { |
| 213 | threadRef: ScopedThreadRef; | ||
| 211 | threadId: ThreadId; | 214 | threadId: ThreadId; |
| 212 | terminalId: string; | 215 | terminalId: string; |
| 213 | terminalLabel: string; | 216 | terminalLabel: string; |
| ... | @@ -222,7 +225,8 @@ interface TerminalViewportProps { | ... | @@ -222,7 +225,8 @@ interface TerminalViewportProps { |
| 222 | drawerHeight: number; | 225 | drawerHeight: number; |
| 223 | } | 226 | } |
| 224 | 227 | ||
| 225 | function TerminalViewport({ | 228 | export function TerminalViewport({ |
| 229 | threadRef, | ||
| 226 | threadId, | 230 | threadId, |
| 227 | terminalId, | 231 | terminalId, |
| 228 | terminalLabel, | 232 | terminalLabel, |
| ... | @@ -260,6 +264,9 @@ function TerminalViewport({ | ... | @@ -260,6 +264,9 @@ function TerminalViewport({ |
| 260 | if (!mount) return; | 264 | if (!mount) return; |
| 261 | 265 | ||
| 262 | let disposed = false; | 266 | let disposed = false; |
| 267 | const api = readEnvironmentApi(threadRef.environmentId); | ||
| 268 | const localApi = readLocalApi(); | ||
| 269 | if (!api || !localApi) return; | ||
| 263 | 270 | ||
| 264 | const fitAddon = new FitAddon(); | 271 | const fitAddon = new FitAddon(); |
| 265 | const terminal = new Terminal({ | 272 | const terminal = new Terminal({ |
| ... | @@ -277,9 +284,6 @@ function TerminalViewport({ | ... | @@ -277,9 +284,6 @@ function TerminalViewport({ |
| 277 | terminalRef.current = terminal; | 284 | terminalRef.current = terminal; |
| 278 | fitAddonRef.current = fitAddon; | 285 | fitAddonRef.current = fitAddon; |
| 279 | 286 | ||
| 280 | const api = readNativeApi(); | ||
| 281 | if (!api) return; | ||
| 282 | |||
| 283 | const clearSelectionAction = () => { | 287 | const clearSelectionAction = () => { |
| 284 | selectionActionRequestIdRef.current += 1; | 288 | selectionActionRequestIdRef.current += 1; |
| 285 | if (selectionActionTimerRef.current !== null) { | 289 | if (selectionActionTimerRef.current !== null) { |
| ... | @@ -340,7 +344,7 @@ function TerminalViewport({ | ... | @@ -340,7 +344,7 @@ function TerminalViewport({ |
| 340 | const requestId = ++selectionActionRequestIdRef.current; | 344 | const requestId = ++selectionActionRequestIdRef.current; |
| 341 | selectionActionOpenRef.current = true; | 345 | selectionActionOpenRef.current = true; |
| 342 | try { | 346 | try { |
| 343 | const clicked = await api.contextMenu.show( | 347 | const clicked = await localApi.contextMenu.show( |
| 344 | [{ id: "add-to-chat", label: "Add to chat" }], | 348 | [{ id: "add-to-chat", label: "Add to chat" }], |
| 345 | nextAction.position, | 349 | nextAction.position, |
| 346 | ); | 350 | ); |
| ... | @@ -416,7 +420,7 @@ function TerminalViewport({ | ... | @@ -416,7 +420,7 @@ function TerminalViewport({ |
| 416 | if (!latestTerminal) return; | 420 | if (!latestTerminal) return; |
| 417 | 421 | ||
| 418 | if (match.kind === "url") { | 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 | writeSystemMessage( | 424 | writeSystemMessage( |
| 421 | latestTerminal, | 425 | latestTerminal, |
| 422 | error instanceof Error ? error.message : "Unable to open link", | 426 | error instanceof Error ? error.message : "Unable to open link", |
| ... | @@ -426,7 +430,7 @@ function TerminalViewport({ | ... | @@ -426,7 +430,7 @@ function TerminalViewport({ |
| 426 | } | 430 | } |
| 427 | 431 | ||
| 428 | const target = resolvePathLinkTarget(match.text, cwd); | 432 | const target = resolvePathLinkTarget(match.text, cwd); |
| 429 | void openInPreferredEditor(api, target).catch((error) => { | 433 | void openInPreferredEditor(localApi, target).catch((error) => { |
| 430 | writeSystemMessage( | 434 | writeSystemMessage( |
| 431 | latestTerminal, | 435 | latestTerminal, |
| 432 | error instanceof Error ? error.message : "Unable to open path", | 436 | error instanceof Error ? error.message : "Unable to open path", |
| ... | @@ -573,12 +577,12 @@ function TerminalViewport({ | ... | @@ -573,12 +577,12 @@ function TerminalViewport({ |
| 573 | const previousLastEntryId = | 577 | const previousLastEntryId = |
| 574 | selectTerminalEventEntries( | 578 | selectTerminalEventEntries( |
| 575 | previousState.terminalEventEntriesByKey, | 579 | previousState.terminalEventEntriesByKey, |
| 576 | threadId, | 580 | threadRef, |
| 577 | terminalId, | 581 | terminalId, |
| 578 | ).at(-1)?.id ?? 0; | 582 | ).at(-1)?.id ?? 0; |
| 579 | const nextEntries = selectTerminalEventEntries( | 583 | const nextEntries = selectTerminalEventEntries( |
| 580 | state.terminalEventEntriesByKey, | 584 | state.terminalEventEntriesByKey, |
| 581 | threadId, | 585 | threadRef, |
| 582 | terminalId, | 586 | terminalId, |
| 583 | ); | 587 | ); |
| 584 | const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; | 588 | const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; |
| ... | @@ -608,7 +612,7 @@ function TerminalViewport({ | ... | @@ -608,7 +612,7 @@ function TerminalViewport({ |
| 608 | writeTerminalSnapshot(activeTerminal, snapshot); | 612 | writeTerminalSnapshot(activeTerminal, snapshot); |
| 609 | const bufferedEntries = selectTerminalEventEntries( | 613 | const bufferedEntries = selectTerminalEventEntries( |
| 610 | useTerminalStateStore.getState().terminalEventEntriesByKey, | 614 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 611 | threadId, | 615 | threadRef, |
| 612 | terminalId, | 616 | terminalId, |
| 613 | ); | 617 | ); |
| 614 | const replayEntries = selectTerminalEventEntriesAfterSnapshot( | 618 | const replayEntries = selectTerminalEventEntriesAfterSnapshot( |
| ... | @@ -677,7 +681,7 @@ function TerminalViewport({ | ... | @@ -677,7 +681,7 @@ function TerminalViewport({ |
| 677 | // autoFocus is intentionally omitted; | 681 | // autoFocus is intentionally omitted; |
| 678 | // it is only read at mount time and must not trigger terminal teardown/recreation. | 682 | // it is only read at mount time and must not trigger terminal teardown/recreation. |
| 679 | // eslint-disable-next-line react-hooks/exhaustive-deps | 683 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 680 | }, [cwd, runtimeEnv, terminalId, threadId]); | 684 | }, [cwd, runtimeEnv, terminalId, threadId, threadRef]); |
| 681 | 685 | ||
| 682 | useEffect(() => { | 686 | useEffect(() => { |
| 683 | if (!autoFocus) return; | 687 | if (!autoFocus) return; |
| ... | @@ -692,7 +696,7 @@ function TerminalViewport({ | ... | @@ -692,7 +696,7 @@ function TerminalViewport({ |
| 692 | }, [autoFocus, focusRequestId]); | 696 | }, [autoFocus, focusRequestId]); |
| 693 | 697 | ||
| 694 | useEffect(() => { | 698 | useEffect(() => { |
| 695 | const api = readNativeApi(); | 699 | const api = readEnvironmentApi(threadRef.environmentId); |
| 696 | const terminal = terminalRef.current; | 700 | const terminal = terminalRef.current; |
| 697 | const fitAddon = fitAddonRef.current; | 701 | const fitAddon = fitAddonRef.current; |
| 698 | if (!api || !terminal || !fitAddon) return; | 702 | if (!api || !terminal || !fitAddon) return; |
| ... | @@ -714,13 +718,14 @@ function TerminalViewport({ | ... | @@ -714,13 +718,14 @@ function TerminalViewport({ |
| 714 | return () => { | 718 | return () => { |
| 715 | window.cancelAnimationFrame(frame); | 719 | window.cancelAnimationFrame(frame); |
| 716 | }; | 720 | }; |
| 717 | }, [drawerHeight, resizeEpoch, terminalId, threadId]); | 721 | }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); |
| 718 | return ( | 722 | return ( |
| 719 | <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" /> | 723 | <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" /> |
| 720 | ); | 724 | ); |
| 721 | } | 725 | } |
| 722 | 726 | ||
| 723 | interface ThreadTerminalDrawerProps { | 727 | interface ThreadTerminalDrawerProps { |
| 728 | threadRef: ScopedThreadRef; | ||
| 724 | threadId: ThreadId; | 729 | threadId: ThreadId; |
| 725 | cwd: string; | 730 | cwd: string; |
| 726 | worktreePath?: string | null; | 731 | worktreePath?: string | null; |
| ... | @@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA | ... | @@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA |
| 773 | } | 778 | } |
| 774 | 779 | ||
| 775 | export default function ThreadTerminalDrawer({ | 780 | export default function ThreadTerminalDrawer({ |
| 781 | threadRef, | ||
| 776 | threadId, | 782 | threadId, |
| 777 | cwd, | 783 | cwd, |
| 778 | worktreePath, | 784 | worktreePath, |
| ... | @@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({ |
| 1098 | > | 1104 | > |
| 1099 | <div className="h-full p-1"> | 1105 | <div className="h-full p-1"> |
| 1100 | <TerminalViewport | 1106 | <TerminalViewport |
| 1107 | threadRef={threadRef} | ||
| 1101 | threadId={threadId} | 1108 | threadId={threadId} |
| 1102 | terminalId={terminalId} | 1109 | terminalId={terminalId} |
| 1103 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} | 1110 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} |
| ... | @@ -1119,6 +1126,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1119,6 +1126,7 @@ export default function ThreadTerminalDrawer({ |
| 1119 | <div className="h-full p-1"> | 1126 | <div className="h-full p-1"> |
| 1120 | <TerminalViewport | 1127 | <TerminalViewport |
| 1121 | key={resolvedActiveTerminalId} | 1128 | key={resolvedActiveTerminalId} |
| 1129 | threadRef={threadRef} | ||
| 1122 | threadId={threadId} | 1130 | threadId={threadId} |
| 1123 | terminalId={resolvedActiveTerminalId} | 1131 | terminalId={resolvedActiveTerminalId} |
| 1124 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} | 1132 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} |
apps/web/src/components/chat/ChatHeader.tsx+10-1| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | ||
| 2 | type EditorId, | 3 | type EditorId, |
| 3 | type ProjectScript, | 4 | type ProjectScript, |
| 4 | type ResolvedKeybindingsConfig, | 5 | type ResolvedKeybindingsConfig, |
| 5 | type ThreadId, | 6 | type ThreadId, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 7 | import { memo } from "react"; | 9 | import { memo } from "react"; |
| 8 | import GitActionsControl from "../GitActionsControl"; | 10 | import GitActionsControl from "../GitActionsControl"; |
| 9 | import { DiffIcon, TerminalSquareIcon } from "lucide-react"; | 11 | import { DiffIcon, TerminalSquareIcon } from "lucide-react"; |
| ... | @@ -15,6 +17,7 @@ import { SidebarTrigger } from "../ui/sidebar"; | ... | @@ -15,6 +17,7 @@ import { SidebarTrigger } from "../ui/sidebar"; |
| 15 | import { OpenInPicker } from "./OpenInPicker"; | 17 | import { OpenInPicker } from "./OpenInPicker"; |
| 16 | 18 | ||
| 17 | interface ChatHeaderProps { | 19 | interface ChatHeaderProps { |
| 20 | activeThreadEnvironmentId: EnvironmentId; | ||
| 18 | activeThreadId: ThreadId; | 21 | activeThreadId: ThreadId; |
| 19 | activeThreadTitle: string; | 22 | activeThreadTitle: string; |
| 20 | activeProjectName: string | undefined; | 23 | activeProjectName: string | undefined; |
| ... | @@ -39,6 +42,7 @@ interface ChatHeaderProps { | ... | @@ -39,6 +42,7 @@ interface ChatHeaderProps { |
| 39 | } | 42 | } |
| 40 | 43 | ||
| 41 | export const ChatHeader = memo(function ChatHeader({ | 44 | export const ChatHeader = memo(function ChatHeader({ |
| 45 | activeThreadEnvironmentId, | ||
| 42 | activeThreadId, | 46 | activeThreadId, |
| 43 | activeThreadTitle, | 47 | activeThreadTitle, |
| 44 | activeProjectName, | 48 | activeProjectName, |
| ... | @@ -101,7 +105,12 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -101,7 +105,12 @@ export const ChatHeader = memo(function ChatHeader({ |
| 101 | openInCwd={openInCwd} | 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 | <Tooltip> | 114 | <Tooltip> |
| 106 | <TooltipTrigger | 115 | <TooltipTrigger |
| 107 | render={ | 116 | render={ |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+35-27| ... | @@ -1,4 +1,10 @@ | ... | @@ -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 | import "../../index.css"; | 8 | import "../../index.css"; |
| 3 | 9 | ||
| 4 | import { page } from "vitest/browser"; | 10 | import { page } from "vitest/browser"; |
| ... | @@ -9,35 +15,37 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; | ... | @@ -9,35 +15,37 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| 9 | import { TraitsMenuContent } from "./TraitsPicker"; | 15 | import { TraitsMenuContent } from "./TraitsPicker"; |
| 10 | import { useComposerDraftStore } from "../../composerDraftStore"; | 16 | import { useComposerDraftStore } from "../../composerDraftStore"; |
| 11 | 17 | ||
| 18 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | ||
| 19 | |||
| 12 | async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { | 20 | async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { |
| 13 | const threadId = ThreadId.makeUnsafe("thread-compact-menu"); | 21 | const threadId = ThreadId.makeUnsafe("thread-compact-menu"); |
| 22 | const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | ||
| 23 | const threadKey = scopedThreadKey(threadRef); | ||
| 14 | const provider = props?.modelSelection?.provider ?? "claudeAgent"; | 24 | const provider = props?.modelSelection?.provider ?? "claudeAgent"; |
| 15 | const draftsByThreadId = {} as ReturnType< | ||
| 16 | typeof useComposerDraftStore.getState | ||
| 17 | >["draftsByThreadId"]; | ||
| 18 | const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; | 25 | const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 19 | 26 | ||
| 20 | draftsByThreadId[threadId] = { | 27 | useComposerDraftStore.setState({ |
| 21 | prompt: props?.prompt ?? "", | 28 | draftsByThreadKey: { |
| 22 | images: [], | 29 | [threadKey]: { |
| 23 | nonPersistedImageIds: [], | 30 | prompt: props?.prompt ?? "", |
| 24 | persistedAttachments: [], | 31 | images: [], |
| 25 | terminalContexts: [], | 32 | nonPersistedImageIds: [], |
| 26 | modelSelectionByProvider: { | 33 | persistedAttachments: [], |
| 27 | [provider]: { | 34 | terminalContexts: [], |
| 28 | provider, | 35 | modelSelectionByProvider: { |
| 29 | model, | 36 | [provider]: { |
| 30 | ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), | 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, | 47 | draftThreadsByThreadKey: {}, |
| 34 | runtimeMode: null, | 48 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 35 | interactionMode: null, | ||
| 36 | }; | ||
| 37 | useComposerDraftStore.setState({ | ||
| 38 | draftsByThreadId, | ||
| 39 | draftThreadsByThreadId: {}, | ||
| 40 | projectDraftThreadIdByProjectId: {}, | ||
| 41 | }); | 49 | }); |
| 42 | const host = document.createElement("div"); | 50 | const host = document.createElement("div"); |
| 43 | document.body.append(host); | 51 | document.body.append(host); |
| ... | @@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str | ... | @@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 121 | <TraitsMenuContent | 129 | <TraitsMenuContent |
| 122 | provider={provider} | 130 | provider={provider} |
| 123 | models={models} | 131 | models={models} |
| 124 | threadId={threadId} | 132 | threadRef={threadRef} |
| 125 | model={model} | 133 | model={model} |
| 126 | prompt={props?.prompt ?? ""} | 134 | prompt={props?.prompt ?? ""} |
| 127 | modelOptions={providerOptions} | 135 | modelOptions={providerOptions} |
| ... | @@ -150,9 +158,9 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -150,9 +158,9 @@ describe("CompactComposerControlsMenu", () => { |
| 150 | afterEach(() => { | 158 | afterEach(() => { |
| 151 | document.body.innerHTML = ""; | 159 | document.body.innerHTML = ""; |
| 152 | useComposerDraftStore.setState({ | 160 | useComposerDraftStore.setState({ |
| 153 | draftsByThreadId: {}, | 161 | draftsByThreadKey: {}, |
| 154 | draftThreadsByThreadId: {}, | 162 | draftThreadsByThreadKey: {}, |
| 155 | projectDraftThreadIdByProjectId: {}, | 163 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 156 | stickyModelSelectionByProvider: {}, | 164 | stickyModelSelectionByProvider: {}, |
| 157 | }); | 165 | }); |
| 158 | }); | 166 | }); |
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+20-18| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { type ApprovalRequestId } from "@t3tools/contracts"; | 1 | import { type ApprovalRequestId } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useEffect, useRef } from "react"; | 2 | import { memo, useEffect, useEffectEvent, useRef } from "react"; |
| 3 | import { type PendingUserInput } from "../../session-logic"; | 3 | import { type PendingUserInput } from "../../session-logic"; |
| 4 | import { | 4 | import { |
| 5 | derivePendingUserInputProgress, | 5 | derivePendingUserInputProgress, |
| ... | @@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 60 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); | 60 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 61 | const activeQuestion = progress.activeQuestion; | 61 | const activeQuestion = progress.activeQuestion; |
| 62 | const autoAdvanceTimerRef = useRef<number | null>(null); | 62 | const autoAdvanceTimerRef = useRef<number | null>(null); |
| 63 | const onAdvanceRef = useRef(onAdvance); | ||
| 64 | |||
| 65 | useEffect(() => { | ||
| 66 | onAdvanceRef.current = onAdvance; | ||
| 67 | }, [onAdvance]); | ||
| 63 | 68 | ||
| 64 | // Clear auto-advance timer on unmount | 69 | // Clear auto-advance timer on unmount |
| 65 | useEffect(() => { | 70 | useEffect(() => { |
| ... | @@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 70 | }; | 75 | }; |
| 71 | }, []); | 76 | }, []); |
| 72 | 77 | ||
| 73 | const handleOptionSelection = useCallback( | 78 | const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { |
| 74 | (questionId: string, optionLabel: string) => { | 79 | onToggleOption(questionId, optionLabel); |
| 75 | onToggleOption(questionId, optionLabel); | 80 | if (activeQuestion?.multiSelect) { |
| 76 | if (activeQuestion?.multiSelect) { | 81 | return; |
| 77 | return; | 82 | } |
| 78 | } | 83 | if (autoAdvanceTimerRef.current !== null) { |
| 79 | if (autoAdvanceTimerRef.current !== null) { | 84 | window.clearTimeout(autoAdvanceTimerRef.current); |
| 80 | window.clearTimeout(autoAdvanceTimerRef.current); | 85 | } |
| 81 | } | 86 | autoAdvanceTimerRef.current = window.setTimeout(() => { |
| 82 | autoAdvanceTimerRef.current = window.setTimeout(() => { | 87 | autoAdvanceTimerRef.current = null; |
| 83 | autoAdvanceTimerRef.current = null; | 88 | onAdvanceRef.current(); |
| 84 | onAdvance(); | 89 | }, 200); |
| 85 | }, 200); | 90 | }); |
| 86 | }, | ||
| 87 | [activeQuestion?.multiSelect, onAdvance, onToggleOption], | ||
| 88 | ); | ||
| 89 | 91 | ||
| 90 | // Keyboard shortcut: number keys 1-9 select corresponding options when focus is | 92 | // Keyboard shortcut: number keys 1-9 select corresponding options when focus is |
| 91 | // outside editable fields. Multi-select prompts toggle options in place; single- | 93 | // outside editable fields. Multi-select prompts toggle options in place; single- |
| ... | @@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 112 | }; | 114 | }; |
| 113 | document.addEventListener("keydown", handler); | 115 | document.addEventListener("keydown", handler); |
| 114 | return () => document.removeEventListener("keydown", handler); | 116 | return () => document.removeEventListener("keydown", handler); |
| 115 | }, [activeQuestion, handleOptionSelection, isResponding]); | 117 | }, [activeQuestion, isResponding]); |
| 116 | 118 | ||
| 117 | if (!activeQuestion) { | 119 | if (!activeQuestion) { |
| 118 | return null; | 120 | return null; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+4| ... | @@ -42,6 +42,8 @@ beforeAll(() => { | ... | @@ -42,6 +42,8 @@ beforeAll(() => { |
| 42 | }); | 42 | }); |
| 43 | }); | 43 | }); |
| 44 | 44 | ||
| 45 | const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; | ||
| 46 | |||
| 45 | describe("MessagesTimeline", () => { | 47 | describe("MessagesTimeline", () => { |
| 46 | it("renders inline terminal labels with the composer chip UI", async () => { | 48 | it("renders inline terminal labels with the composer chip UI", async () => { |
| 47 | const { MessagesTimeline } = await import("./MessagesTimeline"); | 49 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| ... | @@ -85,6 +87,7 @@ describe("MessagesTimeline", () => { | ... | @@ -85,6 +87,7 @@ describe("MessagesTimeline", () => { |
| 85 | onRevertUserMessage={() => {}} | 87 | onRevertUserMessage={() => {}} |
| 86 | isRevertingCheckpoint={false} | 88 | isRevertingCheckpoint={false} |
| 87 | onImageExpand={() => {}} | 89 | onImageExpand={() => {}} |
| 90 | activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | ||
| 88 | markdownCwd={undefined} | 91 | markdownCwd={undefined} |
| 89 | resolvedTheme="light" | 92 | resolvedTheme="light" |
| 90 | timestampFormat="locale" | 93 | timestampFormat="locale" |
| ... | @@ -130,6 +133,7 @@ describe("MessagesTimeline", () => { | ... | @@ -130,6 +133,7 @@ describe("MessagesTimeline", () => { |
| 130 | onRevertUserMessage={() => {}} | 133 | onRevertUserMessage={() => {}} |
| 131 | isRevertingCheckpoint={false} | 134 | isRevertingCheckpoint={false} |
| 132 | onImageExpand={() => {}} | 135 | onImageExpand={() => {}} |
| 136 | activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | ||
| 133 | markdownCwd={undefined} | 137 | markdownCwd={undefined} |
| 134 | resolvedTheme="light" | 138 | resolvedTheme="light" |
| 135 | timestampFormat="locale" | 139 | timestampFormat="locale" |
apps/web/src/components/chat/MessagesTimeline.tsx+4-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -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 | import { | 2 | import { |
| 3 | memo, | 3 | memo, |
| 4 | useCallback, | 4 | useCallback, |
| ... | @@ -82,6 +82,7 @@ interface MessagesTimelineProps { | ... | @@ -82,6 +82,7 @@ interface MessagesTimelineProps { |
| 82 | onRevertUserMessage: (messageId: MessageId) => void; | 82 | onRevertUserMessage: (messageId: MessageId) => void; |
| 83 | isRevertingCheckpoint: boolean; | 83 | isRevertingCheckpoint: boolean; |
| 84 | onImageExpand: (preview: ExpandedImagePreview) => void; | 84 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 85 | activeThreadEnvironmentId: EnvironmentId; | ||
| 85 | markdownCwd: string | undefined; | 86 | markdownCwd: string | undefined; |
| 86 | resolvedTheme: "light" | "dark"; | 87 | resolvedTheme: "light" | "dark"; |
| 87 | timestampFormat: TimestampFormat; | 88 | timestampFormat: TimestampFormat; |
| ... | @@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 117 | onRevertUserMessage, | 118 | onRevertUserMessage, |
| 118 | isRevertingCheckpoint, | 119 | isRevertingCheckpoint, |
| 119 | onImageExpand, | 120 | onImageExpand, |
| 121 | activeThreadEnvironmentId, | ||
| 120 | markdownCwd, | 122 | markdownCwd, |
| 121 | resolvedTheme, | 123 | resolvedTheme, |
| 122 | timestampFormat, | 124 | timestampFormat, |
| ... | @@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 531 | <div className="min-w-0 px-1 py-0.5"> | 533 | <div className="min-w-0 px-1 py-0.5"> |
| 532 | <ProposedPlanCard | 534 | <ProposedPlanCard |
| 533 | planMarkdown={row.proposedPlan.planMarkdown} | 535 | planMarkdown={row.proposedPlan.planMarkdown} |
| 536 | environmentId={activeThreadEnvironmentId} | ||
| 534 | cwd={markdownCwd} | 537 | cwd={markdownCwd} |
| 535 | workspaceRoot={workspaceRoot} | 538 | workspaceRoot={workspaceRoot} |
| 536 | /> | 539 | /> |
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+23-6| ... | @@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { | ... | @@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { |
| 19 | height: 1_100, | 19 | height: 1_100, |
| 20 | }; | 20 | }; |
| 21 | const MARKDOWN_CWD = "/repo/project"; | 21 | const MARKDOWN_CWD = "/repo/project"; |
| 22 | const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; | ||
| 22 | 23 | ||
| 23 | interface RowMeasurement { | 24 | interface RowMeasurement { |
| 24 | actualHeightPx: number; | 25 | actualHeightPx: number; |
| ... | @@ -31,7 +32,10 @@ interface RowMeasurement { | ... | @@ -31,7 +32,10 @@ interface RowMeasurement { |
| 31 | interface VirtualizationScenario { | 32 | interface VirtualizationScenario { |
| 32 | name: string; | 33 | name: string; |
| 33 | targetRowId: string; | 34 | targetRowId: string; |
| 34 | props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | 35 | props: Omit< |
| 36 | ComponentProps<typeof MessagesTimeline>, | ||
| 37 | "scrollContainer" | "activeThreadEnvironmentId" | ||
| 38 | >; | ||
| 35 | maxEstimateDeltaPx: number; | 39 | maxEstimateDeltaPx: number; |
| 36 | } | 40 | } |
| 37 | 41 | ||
| ... | @@ -48,7 +52,10 @@ interface VirtualizerSnapshot { | ... | @@ -48,7 +52,10 @@ interface VirtualizerSnapshot { |
| 48 | } | 52 | } |
| 49 | 53 | ||
| 50 | function MessagesTimelineBrowserHarness( | 54 | function MessagesTimelineBrowserHarness( |
| 51 | props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">, | 55 | props: Omit< |
| 56 | ComponentProps<typeof MessagesTimeline>, | ||
| 57 | "scrollContainer" | "activeThreadEnvironmentId" | ||
| 58 | >, | ||
| 52 | ) { | 59 | ) { |
| 53 | const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null); | 60 | const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null); |
| 54 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>( | 61 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>( |
| ... | @@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness( | ... | @@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness( |
| 73 | > | 80 | > |
| 74 | <MessagesTimeline | 81 | <MessagesTimeline |
| 75 | {...props} | 82 | {...props} |
| 83 | activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} | ||
| 76 | scrollContainer={scrollContainer} | 84 | scrollContainer={scrollContainer} |
| 77 | expandedWorkGroups={expandedWorkGroups} | 85 | expandedWorkGroups={expandedWorkGroups} |
| 78 | onToggleWorkGroup={handleToggleWorkGroup} | 86 | onToggleWorkGroup={handleToggleWorkGroup} |
| ... | @@ -143,7 +151,7 @@ function createBaseTimelineProps(input: { | ... | @@ -143,7 +151,7 @@ function createBaseTimelineProps(input: { |
| 143 | completionDividerBeforeEntryId?: string | null; | 151 | completionDividerBeforeEntryId?: string | null; |
| 144 | turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>; | 152 | turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>; |
| 145 | onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"]; | 153 | onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"]; |
| 146 | }): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer"> { | 154 | }): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer" | "activeThreadEnvironmentId"> { |
| 147 | return { | 155 | return { |
| 148 | hasMessages: true, | 156 | hasMessages: true, |
| 149 | isWorking: false, | 157 | isWorking: false, |
| ... | @@ -481,7 +489,10 @@ async function waitForElement<T extends Element>( | ... | @@ -481,7 +489,10 @@ async function waitForElement<T extends Element>( |
| 481 | 489 | ||
| 482 | async function measureTimelineRow(input: { | 490 | async function measureTimelineRow(input: { |
| 483 | host: HTMLElement; | 491 | host: HTMLElement; |
| 484 | props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | 492 | props: Omit< |
| 493 | ComponentProps<typeof MessagesTimeline>, | ||
| 494 | "scrollContainer" | "activeThreadEnvironmentId" | ||
| 495 | >; | ||
| 485 | targetRowId: string; | 496 | targetRowId: string; |
| 486 | }): Promise<RowMeasurement> { | 497 | }): Promise<RowMeasurement> { |
| 487 | const scrollContainer = await waitForElement( | 498 | const scrollContainer = await waitForElement( |
| ... | @@ -550,7 +561,10 @@ async function measureTimelineRow(input: { | ... | @@ -550,7 +561,10 @@ async function measureTimelineRow(input: { |
| 550 | } | 561 | } |
| 551 | 562 | ||
| 552 | async function mountMessagesTimeline(input: { | 563 | async function mountMessagesTimeline(input: { |
| 553 | props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | 564 | props: Omit< |
| 565 | ComponentProps<typeof MessagesTimeline>, | ||
| 566 | "scrollContainer" | "activeThreadEnvironmentId" | ||
| 567 | >; | ||
| 554 | viewport?: { width: number; height: number }; | 568 | viewport?: { width: number; height: number }; |
| 555 | }) { | 569 | }) { |
| 556 | const viewport = input.viewport ?? DEFAULT_VIEWPORT; | 570 | const viewport = input.viewport ?? DEFAULT_VIEWPORT; |
| ... | @@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: { | ... | @@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: { |
| 576 | return { | 590 | return { |
| 577 | host, | 591 | host, |
| 578 | rerender: async ( | 592 | rerender: async ( |
| 579 | nextProps: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">, | 593 | nextProps: Omit< |
| 594 | ComponentProps<typeof MessagesTimeline>, | ||
| 595 | "scrollContainer" | "activeThreadEnvironmentId" | ||
| 596 | >, | ||
| 580 | ) => { | 597 | ) => { |
| 581 | await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />); | 598 | await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />); |
| 582 | await waitForLayout(); | 599 | await waitForLayout(); |
apps/web/src/components/chat/OpenInPicker.tsx+3-3| ... | @@ -18,7 +18,7 @@ import { | ... | @@ -18,7 +18,7 @@ import { |
| 18 | Zed, | 18 | Zed, |
| 19 | } from "../Icons"; | 19 | } from "../Icons"; |
| 20 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 20 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 21 | import { readNativeApi } from "~/nativeApi"; | 21 | import { readLocalApi } from "~/localApi"; |
| 22 | 22 | ||
| 23 | const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => { | 23 | const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => { |
| 24 | const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ | 24 | const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ |
| ... | @@ -93,7 +93,7 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -93,7 +93,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 93 | 93 | ||
| 94 | const openInEditor = useCallback( | 94 | const openInEditor = useCallback( |
| 95 | (editorId: EditorId | null) => { | 95 | (editorId: EditorId | null) => { |
| 96 | const api = readNativeApi(); | 96 | const api = readLocalApi(); |
| 97 | if (!api || !openInCwd) return; | 97 | if (!api || !openInCwd) return; |
| 98 | const editor = editorId ?? preferredEditor; | 98 | const editor = editorId ?? preferredEditor; |
| 99 | if (!editor) return; | 99 | if (!editor) return; |
| ... | @@ -110,7 +110,7 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -110,7 +110,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 110 | 110 | ||
| 111 | useEffect(() => { | 111 | useEffect(() => { |
| 112 | const handler = (e: globalThis.KeyboardEvent) => { | 112 | const handler = (e: globalThis.KeyboardEvent) => { |
| 113 | const api = readNativeApi(); | 113 | const api = readLocalApi(); |
| 114 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; | 114 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 115 | if (!api || !openInCwd) return; | 115 | if (!api || !openInCwd) return; |
| 116 | if (!preferredEditor) return; | 116 | if (!preferredEditor) return; |
apps/web/src/components/chat/ProposedPlanCard.tsx+5-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { memo, useState, useId } from "react"; | 1 | import { memo, useState, useId } from "react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { | 3 | import { |
| 3 | buildCollapsedProposedPlanPreviewMarkdown, | 4 | buildCollapsedProposedPlanPreviewMarkdown, |
| 4 | buildProposedPlanMarkdownFilename, | 5 | buildProposedPlanMarkdownFilename, |
| ... | @@ -24,15 +25,17 @@ import { | ... | @@ -24,15 +25,17 @@ import { |
| 24 | DialogTitle, | 25 | DialogTitle, |
| 25 | } from "../ui/dialog"; | 26 | } from "../ui/dialog"; |
| 26 | import { toastManager } from "../ui/toast"; | 27 | import { toastManager } from "../ui/toast"; |
| 27 | import { readNativeApi } from "~/nativeApi"; | 28 | import { readEnvironmentApi } from "~/environmentApi"; |
| 28 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 29 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 29 | 30 | ||
| 30 | export const ProposedPlanCard = memo(function ProposedPlanCard({ | 31 | export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 31 | planMarkdown, | 32 | planMarkdown, |
| 33 | environmentId, | ||
| 32 | cwd, | 34 | cwd, |
| 33 | workspaceRoot, | 35 | workspaceRoot, |
| 34 | }: { | 36 | }: { |
| 35 | planMarkdown: string; | 37 | planMarkdown: string; |
| 38 | environmentId: EnvironmentId; | ||
| 36 | cwd: string | undefined; | 39 | cwd: string | undefined; |
| 37 | workspaceRoot: string | undefined; | 40 | workspaceRoot: string | undefined; |
| 38 | }) { | 41 | }) { |
| ... | @@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 82 | }; | 85 | }; |
| 83 | 86 | ||
| 84 | const handleSaveToWorkspace = () => { | 87 | const handleSaveToWorkspace = () => { |
| 85 | const api = readNativeApi(); | 88 | const api = readEnvironmentApi(environmentId); |
| 86 | const relativePath = savePath.trim(); | 89 | const relativePath = savePath.trim(); |
| 87 | if (!api || !workspaceRoot) { | 90 | if (!api || !workspaceRoot) { |
| 88 | return; | 91 | return; |
apps/web/src/components/chat/TraitsPicker.browser.tsx+30-24| ... | @@ -6,10 +6,11 @@ import { | ... | @@ -6,10 +6,11 @@ import { |
| 6 | CodexModelOptions, | 6 | CodexModelOptions, |
| 7 | DEFAULT_MODEL_BY_PROVIDER, | 7 | DEFAULT_MODEL_BY_PROVIDER, |
| 8 | DEFAULT_SERVER_SETTINGS, | 8 | DEFAULT_SERVER_SETTINGS, |
| 9 | ProjectId, | 9 | EnvironmentId, |
| 10 | type ServerProvider, | 10 | type ServerProvider, |
| 11 | ThreadId, | 11 | ThreadId, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 13 | import { page } from "vitest/browser"; | 14 | import { page } from "vitest/browser"; |
| 14 | import { useCallback } from "react"; | 15 | import { useCallback } from "react"; |
| 15 | import { afterEach, describe, expect, it, vi } from "vitest"; | 16 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| ... | @@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | ... | @@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| 27 | 28 | ||
| 28 | // ── Claude TraitsPicker tests ───────────────────────────────────────── | 29 | // ── Claude TraitsPicker tests ───────────────────────────────────────── |
| 29 | 30 | ||
| 31 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); | ||
| 30 | const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); | 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 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | 38 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 32 | { | 39 | { |
| 33 | provider: "codex", | 40 | provider: "codex", |
| ... | @@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: { | ... | @@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: { |
| 120 | fallbackModelSelection: ModelSelection | null; | 127 | fallbackModelSelection: ModelSelection | null; |
| 121 | triggerVariant?: "ghost" | "outline"; | 128 | triggerVariant?: "ghost" | "outline"; |
| 122 | }) { | 129 | }) { |
| 123 | const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt; | 130 | const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt; |
| 124 | const setPrompt = useComposerDraftStore((store) => store.setPrompt); | 131 | const setPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 125 | const { modelOptions, selectedModel } = useEffectiveComposerModelState({ | 132 | const { modelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 126 | threadId: CLAUDE_THREAD_ID, | 133 | threadRef: CLAUDE_THREAD_REF, |
| 127 | providers: TEST_PROVIDERS, | 134 | providers: TEST_PROVIDERS, |
| 128 | selectedProvider: "claudeAgent", | 135 | selectedProvider: "claudeAgent", |
| 129 | threadModelSelection: props.fallbackModelSelection, | 136 | threadModelSelection: props.fallbackModelSelection, |
| ... | @@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: { | ... | @@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: { |
| 135 | }); | 142 | }); |
| 136 | const handlePromptChange = useCallback( | 143 | const handlePromptChange = useCallback( |
| 137 | (nextPrompt: string) => { | 144 | (nextPrompt: string) => { |
| 138 | setPrompt(CLAUDE_THREAD_ID, nextPrompt); | 145 | setPrompt(CLAUDE_THREAD_REF, nextPrompt); |
| 139 | }, | 146 | }, |
| 140 | [setPrompt], | 147 | [setPrompt], |
| 141 | ); | 148 | ); |
| ... | @@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: { | ... | @@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: { |
| 144 | <TraitsPicker | 151 | <TraitsPicker |
| 145 | provider="claudeAgent" | 152 | provider="claudeAgent" |
| 146 | models={TEST_PROVIDERS[1]!.models} | 153 | models={TEST_PROVIDERS[1]!.models} |
| 147 | threadId={CLAUDE_THREAD_ID} | 154 | threadRef={CLAUDE_THREAD_REF} |
| 148 | model={selectedModel ?? props.model} | 155 | model={selectedModel ?? props.model} |
| 149 | prompt={prompt} | 156 | prompt={prompt} |
| 150 | modelOptions={modelOptions?.claudeAgent} | 157 | modelOptions={modelOptions?.claudeAgent} |
| ... | @@ -168,8 +175,8 @@ async function mountClaudePicker(props?: { | ... | @@ -168,8 +175,8 @@ async function mountClaudePicker(props?: { |
| 168 | }) { | 175 | }) { |
| 169 | const model = props?.model ?? "claude-opus-4-6"; | 176 | const model = props?.model ?? "claude-opus-4-6"; |
| 170 | const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined; | 177 | const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined; |
| 171 | const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = { | 178 | const draftsByThreadKey: Record<string, ComposerThreadDraftState> = { |
| 172 | [CLAUDE_THREAD_ID]: { | 179 | [CLAUDE_THREAD_KEY]: { |
| 173 | prompt: props?.prompt ?? "", | 180 | prompt: props?.prompt ?? "", |
| 174 | images: [], | 181 | images: [], |
| 175 | nonPersistedImageIds: [], | 182 | nonPersistedImageIds: [], |
| ... | @@ -192,9 +199,9 @@ async function mountClaudePicker(props?: { | ... | @@ -192,9 +199,9 @@ async function mountClaudePicker(props?: { |
| 192 | }, | 199 | }, |
| 193 | }; | 200 | }; |
| 194 | useComposerDraftStore.setState({ | 201 | useComposerDraftStore.setState({ |
| 195 | draftsByThreadId, | 202 | draftsByThreadKey, |
| 196 | draftThreadsByThreadId: {}, | 203 | draftThreadsByThreadKey: {}, |
| 197 | projectDraftThreadIdByProjectId: {}, | 204 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 198 | }); | 205 | }); |
| 199 | const host = document.createElement("div"); | 206 | const host = document.createElement("div"); |
| 200 | document.body.append(host); | 207 | document.body.append(host); |
| ... | @@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => { | ... | @@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => { |
| 230 | afterEach(() => { | 237 | afterEach(() => { |
| 231 | document.body.innerHTML = ""; | 238 | document.body.innerHTML = ""; |
| 232 | useComposerDraftStore.setState({ | 239 | useComposerDraftStore.setState({ |
| 233 | draftsByThreadId: {}, | 240 | draftsByThreadKey: {}, |
| 234 | draftThreadsByThreadId: {}, | 241 | draftThreadsByThreadKey: {}, |
| 235 | projectDraftThreadIdByProjectId: {}, | 242 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 236 | stickyModelSelectionByProvider: {}, | 243 | stickyModelSelectionByProvider: {}, |
| 237 | }); | 244 | }); |
| 238 | }); | 245 | }); |
| ... | @@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => { | ... | @@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => { |
| 369 | // ── Codex TraitsPicker tests ────────────────────────────────────────── | 376 | // ── Codex TraitsPicker tests ────────────────────────────────────────── |
| 370 | 377 | ||
| 371 | async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { | 378 | async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { |
| 372 | const threadId = ThreadId.makeUnsafe("thread-codex-traits"); | ||
| 373 | const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; | 379 | const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; |
| 374 | const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = { | 380 | const draftsByThreadKey: Record<string, ComposerThreadDraftState> = { |
| 375 | [threadId]: { | 381 | [CODEX_THREAD_KEY]: { |
| 376 | prompt: "", | 382 | prompt: "", |
| 377 | images: [], | 383 | images: [], |
| 378 | nonPersistedImageIds: [], | 384 | nonPersistedImageIds: [], |
| ... | @@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt | ... | @@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt |
| 392 | }; | 398 | }; |
| 393 | 399 | ||
| 394 | useComposerDraftStore.setState({ | 400 | useComposerDraftStore.setState({ |
| 395 | draftsByThreadId, | 401 | draftsByThreadKey, |
| 396 | draftThreadsByThreadId: {}, | 402 | draftThreadsByThreadKey: {}, |
| 397 | projectDraftThreadIdByProjectId: { | 403 | logicalProjectDraftThreadKeyByLogicalProjectKey: { |
| 398 | [ProjectId.makeUnsafe("project-codex-traits")]: threadId, | 404 | "environment-local:project-codex-traits": CODEX_THREAD_KEY, |
| 399 | }, | 405 | }, |
| 400 | }); | 406 | }); |
| 401 | const host = document.createElement("div"); | 407 | const host = document.createElement("div"); |
| ... | @@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt | ... | @@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt |
| 404 | <TraitsPicker | 410 | <TraitsPicker |
| 405 | provider="codex" | 411 | provider="codex" |
| 406 | models={TEST_PROVIDERS[0]!.models} | 412 | models={TEST_PROVIDERS[0]!.models} |
| 407 | threadId={threadId} | 413 | threadRef={CODEX_THREAD_REF} |
| 408 | model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex} | 414 | model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex} |
| 409 | prompt="" | 415 | prompt="" |
| 410 | modelOptions={props.options} | 416 | modelOptions={props.options} |
| ... | @@ -429,9 +435,9 @@ describe("TraitsPicker (Codex)", () => { | ... | @@ -429,9 +435,9 @@ describe("TraitsPicker (Codex)", () => { |
| 429 | document.body.innerHTML = ""; | 435 | document.body.innerHTML = ""; |
| 430 | localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); | 436 | localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); |
| 431 | useComposerDraftStore.setState({ | 437 | useComposerDraftStore.setState({ |
| 432 | draftsByThreadId: {}, | 438 | draftsByThreadKey: {}, |
| 433 | draftThreadsByThreadId: {}, | 439 | draftThreadsByThreadKey: {}, |
| 434 | projectDraftThreadIdByProjectId: {}, | 440 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 435 | stickyModelSelectionByProvider: {}, | 441 | stickyModelSelectionByProvider: {}, |
| 436 | }); | 442 | }); |
| 437 | }); | 443 | }); |
apps/web/src/components/chat/TraitsPicker.tsx+12-5| ... | @@ -3,8 +3,8 @@ import { | ... | @@ -3,8 +3,8 @@ import { |
| 3 | type CodexModelOptions, | 3 | type CodexModelOptions, |
| 4 | type ProviderKind, | 4 | type ProviderKind, |
| 5 | type ProviderModelOptions, | 5 | type ProviderModelOptions, |
| 6 | type ScopedThreadRef, | ||
| 6 | type ServerProviderModel, | 7 | type ServerProviderModel, |
| 7 | type ThreadId, | ||
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { | 9 | import { |
| 10 | applyClaudePromptEffortPrefix, | 10 | applyClaudePromptEffortPrefix, |
| ... | @@ -28,18 +28,19 @@ import { | ... | @@ -28,18 +28,19 @@ import { |
| 28 | MenuSeparator as MenuDivider, | 28 | MenuSeparator as MenuDivider, |
| 29 | MenuTrigger, | 29 | MenuTrigger, |
| 30 | } from "../ui/menu"; | 30 | } from "../ui/menu"; |
| 31 | import { useComposerDraftStore } from "../../composerDraftStore"; | 31 | import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; |
| 32 | import { getProviderModelCapabilities } from "../../providerModels"; | 32 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 33 | import { cn } from "~/lib/utils"; | 33 | import { cn } from "~/lib/utils"; |
| 34 | 34 | ||
| 35 | type ProviderOptions = ProviderModelOptions[ProviderKind]; | 35 | type ProviderOptions = ProviderModelOptions[ProviderKind]; |
| 36 | type TraitsPersistence = | 36 | type TraitsPersistence = |
| 37 | | { | 37 | | { |
| 38 | threadId: ThreadId; | 38 | threadRef?: ScopedThreadRef; |
| 39 | draftId?: DraftId; | ||
| 39 | onModelOptionsChange?: never; | 40 | onModelOptionsChange?: never; |
| 40 | } | 41 | } |
| 41 | | { | 42 | | { |
| 42 | threadId?: undefined; | 43 | threadRef?: undefined; |
| 43 | onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; | 44 | onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; |
| 44 | }; | 45 | }; |
| 45 | 46 | ||
| ... | @@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 167 | persistence.onModelOptionsChange(nextOptions); | 168 | persistence.onModelOptionsChange(nextOptions); |
| 168 | return; | 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 | [persistence, provider, setProviderModelOptions], | 179 | [persistence, provider, setProviderModelOptions], |
| 173 | ); | 180 | ); |
apps/web/src/components/chat/composerProviderRegistry.test.tsx+33-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | 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 | const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [ | 9 | const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 6 | { | 10 | { |
| ... | @@ -417,3 +421,31 @@ describe("getComposerProviderState", () => { | ... | @@ -417,3 +421,31 @@ describe("getComposerProviderState", () => { |
| 417 | expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); | 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,11 +1,12 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProviderKind, | 2 | type ProviderKind, |
| 3 | type ProviderModelOptions, | 3 | type ProviderModelOptions, |
| 4 | type ScopedThreadRef, | ||
| 4 | type ServerProviderModel, | 5 | type ServerProviderModel, |
| 5 | type ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; | 7 | import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; |
| 8 | import type { ReactNode } from "react"; | 8 | import type { ReactNode } from "react"; |
| 9 | import type { DraftId } from "../../composerDraftStore"; | ||
| 9 | import { getProviderModelCapabilities } from "../../providerModels"; | 10 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 10 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; | 11 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 11 | import { | 12 | import { |
| ... | @@ -33,7 +34,8 @@ export type ComposerProviderState = { | ... | @@ -33,7 +34,8 @@ export type ComposerProviderState = { |
| 33 | type ProviderRegistryEntry = { | 34 | type ProviderRegistryEntry = { |
| 34 | getState: (input: ComposerProviderStateInput) => ComposerProviderState; | 35 | getState: (input: ComposerProviderStateInput) => ComposerProviderState; |
| 35 | renderTraitsMenuContent: (input: { | 36 | renderTraitsMenuContent: (input: { |
| 36 | threadId: ThreadId; | 37 | threadRef?: ScopedThreadRef; |
| 38 | draftId?: DraftId; | ||
| 37 | model: string; | 39 | model: string; |
| 38 | models: ReadonlyArray<ServerProviderModel>; | 40 | models: ReadonlyArray<ServerProviderModel>; |
| 39 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | 41 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| ... | @@ -41,7 +43,8 @@ type ProviderRegistryEntry = { | ... | @@ -41,7 +43,8 @@ type ProviderRegistryEntry = { |
| 41 | onPromptChange: (prompt: string) => void; | 43 | onPromptChange: (prompt: string) => void; |
| 42 | }) => ReactNode; | 44 | }) => ReactNode; |
| 43 | renderTraitsPicker: (input: { | 45 | renderTraitsPicker: (input: { |
| 44 | threadId: ThreadId; | 46 | threadRef?: ScopedThreadRef; |
| 47 | draftId?: DraftId; | ||
| 45 | model: string; | 48 | model: string; |
| 46 | models: ReadonlyArray<ServerProviderModel>; | 49 | models: ReadonlyArray<ServerProviderModel>; |
| 47 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | 50 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| ... | @@ -50,6 +53,13 @@ type ProviderRegistryEntry = { | ... | @@ -50,6 +53,13 @@ type ProviderRegistryEntry = { |
| 50 | }) => ReactNode; | 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 | function getProviderStateFromCapabilities( | 63 | function getProviderStateFromCapabilities( |
| 54 | input: ComposerProviderStateInput, | 64 | input: ComposerProviderStateInput, |
| 55 | ): ComposerProviderState { | 65 | ): ComposerProviderState { |
| ... | @@ -94,66 +104,92 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | ... | @@ -94,66 +104,92 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 94 | codex: { | 104 | codex: { |
| 95 | getState: (input) => getProviderStateFromCapabilities(input), | 105 | getState: (input) => getProviderStateFromCapabilities(input), |
| 96 | renderTraitsMenuContent: ({ | 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 | model, | 130 | model, |
| 99 | models, | 131 | models, |
| 100 | modelOptions, | 132 | modelOptions, |
| 101 | prompt, | 133 | prompt, |
| 102 | onPromptChange, | 134 | onPromptChange, |
| 103 | }) => ( | 135 | }) => |
| 104 | <TraitsMenuContent | 136 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( |
| 105 | provider="codex" | 137 | <TraitsPicker |
| 106 | models={models} | 138 | provider="codex" |
| 107 | threadId={threadId} | 139 | models={models} |
| 108 | model={model} | 140 | {...(threadRef ? { threadRef } : {})} |
| 109 | modelOptions={modelOptions} | 141 | {...(draftId ? { draftId } : {})} |
| 110 | prompt={prompt} | 142 | model={model} |
| 111 | onPromptChange={onPromptChange} | 143 | modelOptions={modelOptions} |
| 112 | /> | 144 | prompt={prompt} |
| 113 | ), | 145 | onPromptChange={onPromptChange} |
| 114 | renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( | 146 | /> |
| 115 | <TraitsPicker | 147 | ), |
| 116 | provider="codex" | ||
| 117 | models={models} | ||
| 118 | threadId={threadId} | ||
| 119 | model={model} | ||
| 120 | modelOptions={modelOptions} | ||
| 121 | prompt={prompt} | ||
| 122 | onPromptChange={onPromptChange} | ||
| 123 | /> | ||
| 124 | ), | ||
| 125 | }, | 148 | }, |
| 126 | claudeAgent: { | 149 | claudeAgent: { |
| 127 | getState: (input) => getProviderStateFromCapabilities(input), | 150 | getState: (input) => getProviderStateFromCapabilities(input), |
| 128 | renderTraitsMenuContent: ({ | 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 | model, | 175 | model, |
| 131 | models, | 176 | models, |
| 132 | modelOptions, | 177 | modelOptions, |
| 133 | prompt, | 178 | prompt, |
| 134 | onPromptChange, | 179 | onPromptChange, |
| 135 | }) => ( | 180 | }) => |
| 136 | <TraitsMenuContent | 181 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( |
| 137 | provider="claudeAgent" | 182 | <TraitsPicker |
| 138 | models={models} | 183 | provider="claudeAgent" |
| 139 | threadId={threadId} | 184 | models={models} |
| 140 | model={model} | 185 | {...(threadRef ? { threadRef } : {})} |
| 141 | modelOptions={modelOptions} | 186 | {...(draftId ? { draftId } : {})} |
| 142 | prompt={prompt} | 187 | model={model} |
| 143 | onPromptChange={onPromptChange} | 188 | modelOptions={modelOptions} |
| 144 | /> | 189 | prompt={prompt} |
| 145 | ), | 190 | onPromptChange={onPromptChange} |
| 146 | renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( | 191 | /> |
| 147 | <TraitsPicker | 192 | ), |
| 148 | provider="claudeAgent" | ||
| 149 | models={models} | ||
| 150 | threadId={threadId} | ||
| 151 | model={model} | ||
| 152 | modelOptions={modelOptions} | ||
| 153 | prompt={prompt} | ||
| 154 | onPromptChange={onPromptChange} | ||
| 155 | /> | ||
| 156 | ), | ||
| 157 | }, | 193 | }, |
| 158 | }; | 194 | }; |
| 159 | 195 | ||
| ... | @@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com | ... | @@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com |
| 163 | 199 | ||
| 164 | export function renderProviderTraitsMenuContent(input: { | 200 | export function renderProviderTraitsMenuContent(input: { |
| 165 | provider: ProviderKind; | 201 | provider: ProviderKind; |
| 166 | threadId: ThreadId; | 202 | threadRef?: ScopedThreadRef; |
| 203 | draftId?: DraftId; | ||
| 167 | model: string; | 204 | model: string; |
| 168 | models: ReadonlyArray<ServerProviderModel>; | 205 | models: ReadonlyArray<ServerProviderModel>; |
| 169 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | 206 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| ... | @@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: { | ... | @@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: { |
| 171 | onPromptChange: (prompt: string) => void; | 208 | onPromptChange: (prompt: string) => void; |
| 172 | }): ReactNode { | 209 | }): ReactNode { |
| 173 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ | 210 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ |
| 174 | threadId: input.threadId, | 211 | ...(input.threadRef ? { threadRef: input.threadRef } : {}), |
| 212 | ...(input.draftId ? { draftId: input.draftId } : {}), | ||
| 175 | model: input.model, | 213 | model: input.model, |
| 176 | models: input.models, | 214 | models: input.models, |
| 177 | modelOptions: input.modelOptions, | 215 | modelOptions: input.modelOptions, |
| ... | @@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: { | ... | @@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: { |
| 182 | 220 | ||
| 183 | export function renderProviderTraitsPicker(input: { | 221 | export function renderProviderTraitsPicker(input: { |
| 184 | provider: ProviderKind; | 222 | provider: ProviderKind; |
| 185 | threadId: ThreadId; | 223 | threadRef?: ScopedThreadRef; |
| 224 | draftId?: DraftId; | ||
| 186 | model: string; | 225 | model: string; |
| 187 | models: ReadonlyArray<ServerProviderModel>; | 226 | models: ReadonlyArray<ServerProviderModel>; |
| 188 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | 227 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; |
| ... | @@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: { | ... | @@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: { |
| 190 | onPromptChange: (prompt: string) => void; | 229 | onPromptChange: (prompt: string) => void; |
| 191 | }): ReactNode { | 230 | }): ReactNode { |
| 192 | return composerProviderRegistry[input.provider].renderTraitsPicker({ | 231 | return composerProviderRegistry[input.provider].renderTraitsPicker({ |
| 193 | threadId: input.threadId, | 232 | ...(input.threadRef ? { threadRef: input.threadRef } : {}), |
| 233 | ...(input.draftId ? { draftId: input.draftId } : {}), | ||
| 194 | model: input.model, | 234 | model: input.model, |
| 195 | models: input.models, | 235 | models: input.models, |
| 196 | modelOptions: input.modelOptions, | 236 | modelOptions: input.modelOptions, |
apps/web/src/components/settings/SettingsPanels.browser.tsx+18-6| ... | @@ -1,17 +1,29 @@ | ... | @@ -1,17 +1,29 @@ |
| 1 | import "../../index.css"; | 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 | import { page } from "vitest/browser"; | 9 | import { page } from "vitest/browser"; |
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 6 | import { render } from "vitest-browser-react"; | 11 | import { render } from "vitest-browser-react"; |
| 7 | 12 | ||
| 8 | import { __resetNativeApiForTests } from "../../nativeApi"; | 13 | import { __resetLocalApiForTests } from "../../localApi"; |
| 9 | import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; | 14 | import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; |
| 10 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; | 15 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; |
| 11 | import { GeneralSettingsPanel } from "./SettingsPanels"; | 16 | import { GeneralSettingsPanel } from "./SettingsPanels"; |
| 12 | 17 | ||
| 13 | function createBaseServerConfig(): ServerConfig { | 18 | function createBaseServerConfig(): ServerConfig { |
| 14 | return { | 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 | cwd: "/repo/project", | 27 | cwd: "/repo/project", |
| 16 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | 28 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 17 | keybindings: [], | 29 | keybindings: [], |
| ... | @@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig { |
| 32 | describe("GeneralSettingsPanel observability", () => { | 44 | describe("GeneralSettingsPanel observability", () => { |
| 33 | beforeEach(async () => { | 45 | beforeEach(async () => { |
| 34 | resetServerStateForTests(); | 46 | resetServerStateForTests(); |
| 35 | await __resetNativeApiForTests(); | 47 | await __resetLocalApiForTests(); |
| 36 | localStorage.clear(); | 48 | localStorage.clear(); |
| 37 | document.body.innerHTML = ""; | 49 | document.body.innerHTML = ""; |
| 38 | }); | 50 | }); |
| 39 | 51 | ||
| 40 | afterEach(async () => { | 52 | afterEach(async () => { |
| 41 | resetServerStateForTests(); | 53 | resetServerStateForTests(); |
| 42 | await __resetNativeApiForTests(); | 54 | await __resetLocalApiForTests(); |
| 43 | document.body.innerHTML = ""; | 55 | document.body.innerHTML = ""; |
| 44 | }); | 56 | }); |
| 45 | 57 | ||
| ... | @@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => { |
| 68 | }); | 80 | }); |
| 69 | 81 | ||
| 70 | it("opens the logs folder in the preferred editor", async () => { | 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 | window.nativeApi = { | 84 | window.nativeApi = { |
| 73 | shell: { | 85 | shell: { |
| 74 | openInEditor, | 86 | openInEditor, |
| 75 | }, | 87 | }, |
| 76 | } as unknown as NativeApi; | 88 | } as unknown as LocalApi; |
| 77 | 89 | ||
| 78 | setServerConfigSnapshot(createBaseServerConfig()); | 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,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query"; |
| 13 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 13 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 14 | import { | 14 | import { |
| 15 | PROVIDER_DISPLAY_NAMES, | 15 | PROVIDER_DISPLAY_NAMES, |
| 16 | type ScopedThreadRef, | ||
| 16 | type ProviderKind, | 17 | type ProviderKind, |
| 17 | type ServerProvider, | 18 | type ServerProvider, |
| 18 | type ServerProviderModel, | 19 | type ServerProviderModel, |
| 19 | ThreadId, | ||
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 21 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 22 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 22 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 23 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 23 | import { Equal } from "effect"; | 24 | import { Equal } from "effect"; |
| ... | @@ -45,8 +46,12 @@ import { | ... | @@ -45,8 +46,12 @@ import { |
| 45 | getCustomModelOptionsByProvider, | 46 | getCustomModelOptionsByProvider, |
| 46 | resolveAppModelSelectionState, | 47 | resolveAppModelSelectionState, |
| 47 | } from "../../modelSelection"; | 48 | } from "../../modelSelection"; |
| 48 | import { ensureNativeApi, readNativeApi } from "../../nativeApi"; | 49 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 49 | import { useStore } from "../../store"; | 50 | import { |
| 51 | selectProjectsAcrossEnvironments, | ||
| 52 | selectThreadsAcrossEnvironments, | ||
| 53 | useStore, | ||
| 54 | } from "../../store"; | ||
| 50 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; | 55 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; |
| 51 | import { cn } from "../../lib/utils"; | 56 | import { cn } from "../../lib/utils"; |
| 52 | import { Button } from "../ui/button"; | 57 | import { Button } from "../ui/button"; |
| ... | @@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 496 | 501 | ||
| 497 | const restoreDefaults = useCallback(async () => { | 502 | const restoreDefaults = useCallback(async () => { |
| 498 | if (changedSettingLabels.length === 0) return; | 503 | if (changedSettingLabels.length === 0) return; |
| 499 | const api = readNativeApi(); | 504 | const api = readLocalApi(); |
| 500 | const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( | 505 | const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( |
| 501 | ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( | 506 | ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( |
| 502 | "\n", | 507 | "\n", |
| 503 | ), | 508 | ), |
| ... | @@ -554,7 +559,7 @@ export function GeneralSettingsPanel() { | ... | @@ -554,7 +559,7 @@ export function GeneralSettingsPanel() { |
| 554 | if (refreshingRef.current) return; | 559 | if (refreshingRef.current) return; |
| 555 | refreshingRef.current = true; | 560 | refreshingRef.current = true; |
| 556 | setIsRefreshingProviders(true); | 561 | setIsRefreshingProviders(true); |
| 557 | void ensureNativeApi() | 562 | void ensureLocalApi() |
| 558 | .server.refreshProviders() | 563 | .server.refreshProviders() |
| 559 | .catch((error: unknown) => { | 564 | .catch((error: unknown) => { |
| 560 | console.warn("Failed to refresh providers", error); | 565 | console.warn("Failed to refresh providers", error); |
| ... | @@ -614,7 +619,7 @@ export function GeneralSettingsPanel() { | ... | @@ -614,7 +619,7 @@ export function GeneralSettingsPanel() { |
| 614 | return; | 619 | return; |
| 615 | } | 620 | } |
| 616 | 621 | ||
| 617 | void ensureNativeApi() | 622 | void ensureLocalApi() |
| 618 | .shell.openInEditor(path, editor) | 623 | .shell.openInEditor(path, editor) |
| 619 | .catch((error) => { | 624 | .catch((error) => { |
| 620 | setOpenPathErrorByTarget((existing) => ({ | 625 | setOpenPathErrorByTarget((existing) => ({ |
| ... | @@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() { | ... | @@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() { |
| 1479 | } | 1484 | } |
| 1480 | 1485 | ||
| 1481 | export function ArchivedThreadsPanel() { | 1486 | export function ArchivedThreadsPanel() { |
| 1482 | const projectIds = useStore((store) => store.projectIds); | 1487 | const projects = useStore(selectProjectsAcrossEnvironments); |
| 1483 | const projectById = useStore((store) => store.projectById); | 1488 | const threads = useStore(selectThreadsAcrossEnvironments); |
| 1484 | const threadIds = useStore((store) => store.threadIds); | ||
| 1485 | const threadShellById = useStore((store) => store.threadShellById); | ||
| 1486 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); | 1489 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
| 1487 | const archivedGroups = useMemo(() => { | 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 | return projects | 1491 | return projects |
| 1497 | .map((project) => ({ | 1492 | .map((project) => ({ |
| 1498 | project, | 1493 | project, |
| ... | @@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() { | ... | @@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() { |
| 1505 | }), | 1500 | }), |
| 1506 | })) | 1501 | })) |
| 1507 | .filter((group) => group.threads.length > 0); | 1502 | .filter((group) => group.threads.length > 0); |
| 1508 | }, [projectById, projectIds, threadIds, threadShellById]); | 1503 | }, [projects, threads]); |
| 1509 | 1504 | ||
| 1510 | const handleArchivedThreadContextMenu = useCallback( | 1505 | const handleArchivedThreadContextMenu = useCallback( |
| 1511 | async (threadId: ThreadId, position: { x: number; y: number }) => { | 1506 | async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { |
| 1512 | const api = readNativeApi(); | 1507 | const api = readLocalApi(); |
| 1513 | if (!api) return; | 1508 | if (!api) return; |
| 1514 | const clicked = await api.contextMenu.show( | 1509 | const clicked = await api.contextMenu.show( |
| 1515 | [ | 1510 | [ |
| ... | @@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() { | ... | @@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() { |
| 1521 | 1516 | ||
| 1522 | if (clicked === "unarchive") { | 1517 | if (clicked === "unarchive") { |
| 1523 | try { | 1518 | try { |
| 1524 | await unarchiveThread(threadId); | 1519 | await unarchiveThread(threadRef); |
| 1525 | } catch (error) { | 1520 | } catch (error) { |
| 1526 | toastManager.add({ | 1521 | toastManager.add({ |
| 1527 | type: "error", | 1522 | type: "error", |
| ... | @@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() { | ... | @@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() { |
| 1533 | } | 1528 | } |
| 1534 | 1529 | ||
| 1535 | if (clicked === "delete") { | 1530 | if (clicked === "delete") { |
| 1536 | await confirmAndDeleteThread(threadId); | 1531 | await confirmAndDeleteThread(threadRef); |
| 1537 | } | 1532 | } |
| 1538 | }, | 1533 | }, |
| 1539 | [confirmAndDeleteThread, unarchiveThread], | 1534 | [confirmAndDeleteThread, unarchiveThread], |
| ... | @@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() { | ... | @@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() { |
| 1566 | className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5" | 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 | onContextMenu={(event) => { | 1562 | onContextMenu={(event) => { |
| 1568 | event.preventDefault(); | 1563 | event.preventDefault(); |
| 1569 | void handleArchivedThreadContextMenu(thread.id, { | 1564 | void handleArchivedThreadContextMenu( |
| 1570 | x: event.clientX, | 1565 | scopeThreadRef(thread.environmentId, thread.id), |
| 1571 | y: event.clientY, | 1566 | { |
| 1572 | }); | 1567 | x: event.clientX, |
| 1568 | y: event.clientY, | ||
| 1569 | }, | ||
| 1570 | ); | ||
| 1573 | }} | 1571 | }} |
| 1574 | > | 1572 | > |
| 1575 | <div className="min-w-0 flex-1"> | 1573 | <div className="min-w-0 flex-1"> |
| ... | @@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() { | ... | @@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() { |
| 1586 | size="sm" | 1584 | size="sm" |
| 1587 | className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" | 1585 | className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" |
| 1588 | onClick={() => | 1586 | onClick={() => |
| 1589 | void unarchiveThread(thread.id).catch((error) => { | 1587 | void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch( |
| 1590 | toastManager.add({ | 1588 | (error) => { |
| 1591 | type: "error", | 1589 | toastManager.add({ |
| 1592 | title: "Failed to unarchive thread", | 1590 | type: "error", |
| 1593 | description: error instanceof Error ? error.message : "An error occurred.", | 1591 | title: "Failed to unarchive thread", |
| 1594 | }); | 1592 | description: |
| 1595 | }) | 1593 | error instanceof Error ? error.message : "An error occurred.", |
| 1594 | }); | ||
| 1595 | }, | ||
| 1596 | ) | ||
| 1596 | } | 1597 | } |
| 1597 | > | 1598 | > |
| 1598 | <ArchiveX className="size-3.5" /> | 1599 | <ArchiveX className="size-3.5" /> |
apps/web/src/composerDraftStore.test.ts+302-188| ... | @@ -1,5 +1,12 @@ | ... | @@ -1,5 +1,12 @@ |
| 1 | import { | ||
| 2 | scopedProjectKey, | ||
| 3 | scopedThreadKey, | ||
| 4 | scopeProjectRef, | ||
| 5 | scopeThreadRef, | ||
| 6 | } from "@t3tools/client-runtime"; | ||
| 1 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 2 | import { | 8 | import { |
| 9 | EnvironmentId, | ||
| 3 | ProjectId, | 10 | ProjectId, |
| 4 | ThreadId, | 11 | ThreadId, |
| 5 | type ModelSelection, | 12 | type ModelSelection, |
| ... | @@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ... | @@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 9 | 16 | ||
| 10 | import { | 17 | import { |
| 11 | COMPOSER_DRAFT_STORAGE_KEY, | 18 | COMPOSER_DRAFT_STORAGE_KEY, |
| 12 | clearPromotedDraftThread, | 19 | finalizePromotedDraftThreadByRef, |
| 13 | clearPromotedDraftThreads, | 20 | markPromotedDraftThread, |
| 21 | markPromotedDraftThreadByRef, | ||
| 22 | markPromotedDraftThreads, | ||
| 23 | markPromotedDraftThreadsByRef, | ||
| 14 | type ComposerImageAttachment, | 24 | type ComposerImageAttachment, |
| 15 | useComposerDraftStore, | 25 | useComposerDraftStore, |
| 26 | DraftId, | ||
| 16 | } from "./composerDraftStore"; | 27 | } from "./composerDraftStore"; |
| 17 | import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; | 28 | import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; |
| 18 | import { | 29 | import { |
| ... | @@ -71,9 +82,9 @@ function makeTerminalContext(input: { | ... | @@ -71,9 +82,9 @@ function makeTerminalContext(input: { |
| 71 | 82 | ||
| 72 | function resetComposerDraftStore() { | 83 | function resetComposerDraftStore() { |
| 73 | useComposerDraftStore.setState({ | 84 | useComposerDraftStore.setState({ |
| 74 | draftsByThreadId: {}, | 85 | draftsByThreadKey: {}, |
| 75 | draftThreadsByThreadId: {}, | 86 | draftThreadsByThreadKey: {}, |
| 76 | projectDraftThreadIdByProjectId: {}, | 87 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 77 | stickyModelSelectionByProvider: {}, | 88 | stickyModelSelectionByProvider: {}, |
| 78 | stickyActiveProvider: null, | 89 | stickyActiveProvider: null, |
| 79 | }); | 90 | }); |
| ... | @@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio | ... | @@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio |
| 95 | return options; | 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 | describe("composerDraftStore addImages", () => { | 132 | describe("composerDraftStore addImages", () => { |
| 99 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); | 133 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); |
| 134 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 100 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; | 135 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; |
| 101 | let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; | 136 | let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; |
| 102 | 137 | ||
| ... | @@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => { | ... | @@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => { |
| 129 | lastModified: 12345, | 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 | expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); | 170 | expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); |
| 136 | expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); | 171 | expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); |
| 137 | }); | 172 | }); |
| ... | @@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => { | ... | @@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => { |
| 154 | lastModified: 999, | 189 | lastModified: 999, |
| 155 | }); | 190 | }); |
| 156 | 191 | ||
| 157 | useComposerDraftStore.getState().addImage(threadId, first); | 192 | useComposerDraftStore.getState().addImage(threadRef, first); |
| 158 | useComposerDraftStore.getState().addImage(threadId, duplicateLater); | 193 | useComposerDraftStore.getState().addImage(threadRef, duplicateLater); |
| 159 | 194 | ||
| 160 | const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | 195 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); |
| 161 | expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); | 196 | expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); |
| 162 | expect(revokeSpy).toHaveBeenCalledWith("blob:b"); | 197 | expect(revokeSpy).toHaveBeenCalledWith("blob:b"); |
| 163 | }); | 198 | }); |
| ... | @@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => { | ... | @@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => { |
| 172 | previewUrl: "blob:shared", | 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 | expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); | 213 | expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); |
| 179 | expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); | 214 | expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); |
| 180 | }); | 215 | }); |
| ... | @@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => { | ... | @@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => { |
| 182 | 217 | ||
| 183 | describe("composerDraftStore clearComposerContent", () => { | 218 | describe("composerDraftStore clearComposerContent", () => { |
| 184 | const threadId = ThreadId.makeUnsafe("thread-clear"); | 219 | const threadId = ThreadId.makeUnsafe("thread-clear"); |
| 220 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 185 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; | 221 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; |
| 186 | let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; | 222 | let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; |
| 187 | 223 | ||
| ... | @@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => { | ... | @@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => { |
| 201 | id: "img-optimistic", | 237 | id: "img-optimistic", |
| 202 | previewUrl: "blob:optimistic", | 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 | expect(draft).toBeUndefined(); | 245 | expect(draft).toBeUndefined(); |
| 210 | expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); | 246 | expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); |
| 211 | }); | 247 | }); |
| ... | @@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => { | ... | @@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => { |
| 213 | 249 | ||
| 214 | describe("composerDraftStore syncPersistedAttachments", () => { | 250 | describe("composerDraftStore syncPersistedAttachments", () => { |
| 215 | const threadId = ThreadId.makeUnsafe("thread-sync-persisted"); | 251 | const threadId = ThreadId.makeUnsafe("thread-sync-persisted"); |
| 252 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 216 | 253 | ||
| 217 | beforeEach(() => { | 254 | beforeEach(() => { |
| 218 | removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); | 255 | removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); |
| 219 | useComposerDraftStore.setState({ | 256 | useComposerDraftStore.setState({ |
| 220 | draftsByThreadId: {}, | 257 | draftsByThreadKey: {}, |
| 221 | draftThreadsByThreadId: {}, | 258 | draftThreadsByThreadKey: {}, |
| 222 | projectDraftThreadIdByProjectId: {}, | 259 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 223 | stickyModelSelectionByProvider: {}, | 260 | stickyModelSelectionByProvider: {}, |
| 224 | stickyActiveProvider: null, | 261 | stickyActiveProvider: null, |
| 225 | }); | 262 | }); |
| ... | @@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { | ... | @@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { |
| 234 | id: "img-persisted", | 271 | id: "img-persisted", |
| 235 | previewUrl: "blob:persisted", | 272 | previewUrl: "blob:persisted", |
| 236 | }); | 273 | }); |
| 237 | useComposerDraftStore.getState().addImage(threadId, image); | 274 | useComposerDraftStore.getState().addImage(threadRef, image); |
| 238 | setLocalStorageItem( | 275 | setLocalStorageItem( |
| 239 | COMPOSER_DRAFT_STORAGE_KEY, | 276 | COMPOSER_DRAFT_STORAGE_KEY, |
| 240 | { | 277 | { |
| ... | @@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { | ... | @@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { |
| 250 | Schema.Unknown, | 287 | Schema.Unknown, |
| 251 | ); | 288 | ); |
| 252 | 289 | ||
| 253 | useComposerDraftStore.getState().syncPersistedAttachments(threadId, [ | 290 | useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ |
| 254 | { | 291 | { |
| 255 | id: image.id, | 292 | id: image.id, |
| 256 | name: image.name, | 293 | name: image.name, |
| ... | @@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => { | ... | @@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => { |
| 261 | ]); | 298 | ]); |
| 262 | await Promise.resolve(); | 299 | await Promise.resolve(); |
| 263 | 300 | ||
| 264 | expect( | 301 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]); |
| 265 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments, | 302 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]); |
| 266 | ).toEqual([]); | ||
| 267 | expect( | ||
| 268 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds, | ||
| 269 | ).toEqual([image.id]); | ||
| 270 | }); | 303 | }); |
| 271 | }); | 304 | }); |
| 272 | 305 | ||
| 273 | describe("composerDraftStore terminal contexts", () => { | 306 | describe("composerDraftStore terminal contexts", () => { |
| 274 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); | 307 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); |
| 308 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 275 | 309 | ||
| 276 | beforeEach(() => { | 310 | beforeEach(() => { |
| 277 | useComposerDraftStore.setState({ | 311 | useComposerDraftStore.setState({ |
| 278 | draftsByThreadId: {}, | 312 | draftsByThreadKey: {}, |
| 279 | draftThreadsByThreadId: {}, | 313 | draftThreadsByThreadKey: {}, |
| 280 | projectDraftThreadIdByProjectId: {}, | 314 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 281 | stickyModelSelectionByProvider: {}, | 315 | stickyModelSelectionByProvider: {}, |
| 282 | stickyActiveProvider: null, | 316 | stickyActiveProvider: null, |
| 283 | }); | 317 | }); |
| ... | @@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => { |
| 287 | const first = makeTerminalContext({ id: "ctx-1" }); | 321 | const first = makeTerminalContext({ id: "ctx-1" }); |
| 288 | const duplicate = makeTerminalContext({ id: "ctx-2" }); | 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 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); | 327 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); |
| 294 | }); | 328 | }); |
| 295 | 329 | ||
| 296 | it("clears terminal contexts when clearing composer content", () => { | 330 | it("clears terminal contexts when clearing composer content", () => { |
| 297 | useComposerDraftStore | 331 | useComposerDraftStore |
| 298 | .getState() | 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 | it("inserts terminal contexts at the requested inline prompt position", () => { | 340 | it("inserts terminal contexts at the requested inline prompt position", () => { |
| ... | @@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => { |
| 311 | useComposerDraftStore | 345 | useComposerDraftStore |
| 312 | .getState() | 346 | .getState() |
| 313 | .insertTerminalContext( | 347 | .insertTerminalContext( |
| 314 | threadId, | 348 | threadRef, |
| 315 | firstInsertion.prompt, | 349 | firstInsertion.prompt, |
| 316 | makeTerminalContext({ id: "ctx-1" }), | 350 | makeTerminalContext({ id: "ctx-1" }), |
| 317 | firstInsertion.contextIndex, | 351 | firstInsertion.contextIndex, |
| ... | @@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => { |
| 319 | ).toBe(true); | 353 | ).toBe(true); |
| 320 | expect( | 354 | expect( |
| 321 | useComposerDraftStore.getState().insertTerminalContext( | 355 | useComposerDraftStore.getState().insertTerminalContext( |
| 322 | threadId, | 356 | threadRef, |
| 323 | secondInsertion.prompt, | 357 | secondInsertion.prompt, |
| 324 | makeTerminalContext({ | 358 | makeTerminalContext({ |
| 325 | id: "ctx-2", | 359 | id: "ctx-2", |
| ... | @@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => { |
| 331 | ), | 365 | ), |
| 332 | ).toBe(true); | 366 | ).toBe(true); |
| 333 | 367 | ||
| 334 | const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | 368 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); |
| 335 | expect(draft?.prompt).toBe( | 369 | expect(draft?.prompt).toBe( |
| 336 | `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`, | 370 | `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`, |
| 337 | ); | 371 | ); |
| ... | @@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => { |
| 341 | it("omits terminal context text from persisted drafts", () => { | 375 | it("omits terminal context text from persisted drafts", () => { |
| 342 | useComposerDraftStore | 376 | useComposerDraftStore |
| 343 | .getState() | 377 | .getState() |
| 344 | .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" })); | 378 | .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" })); |
| 345 | 379 | ||
| 346 | const persistApi = useComposerDraftStore.persist as unknown as { | 380 | const persistApi = useComposerDraftStore.persist as unknown as { |
| 347 | getOptions: () => { | 381 | getOptions: () => { |
| ... | @@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => { |
| 349 | }; | 383 | }; |
| 350 | }; | 384 | }; |
| 351 | const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { | 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 | expect( | 389 | expect( |
| 356 | persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0], | 390 | persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] |
| 391 | ?.terminalContexts?.[0], | ||
| 357 | "Expected terminal context metadata to be persisted.", | 392 | "Expected terminal context metadata to be persisted.", |
| 358 | ).toMatchObject({ | 393 | ).toMatchObject({ |
| 359 | id: "ctx-persist", | 394 | id: "ctx-persist", |
| ... | @@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => { |
| 363 | lineEnd: 5, | 398 | lineEnd: 5, |
| 364 | }); | 399 | }); |
| 365 | expect( | 400 | expect( |
| 366 | persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text, | 401 | persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] |
| 402 | ?.terminalContexts?.[0]?.text, | ||
| 367 | ).toBeUndefined(); | 403 | ).toBeUndefined(); |
| 368 | }); | 404 | }); |
| 369 | 405 | ||
| ... | @@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => { |
| 396 | }, | 432 | }, |
| 397 | }, | 433 | }, |
| 398 | draftThreadsByThreadId: {}, | 434 | draftThreadsByThreadId: {}, |
| 399 | projectDraftThreadIdByProjectId: {}, | 435 | projectDraftThreadIdByProjectKey: {}, |
| 400 | }, | 436 | }, |
| 401 | useComposerDraftStore.getInitialState(), | 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 | id: "ctx-rehydrated", | 442 | id: "ctx-rehydrated", |
| 407 | terminalId: "default", | 443 | terminalId: "default", |
| ... | @@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => { |
| 434 | }, | 470 | }, |
| 435 | }, | 471 | }, |
| 436 | draftThreadsByThreadId: "not-an-object", | 472 | draftThreadsByThreadId: "not-an-object", |
| 437 | projectDraftThreadIdByProjectId: "not-an-object", | 473 | projectDraftThreadIdByProjectKey: "not-an-object", |
| 438 | }, | 474 | }, |
| 439 | useComposerDraftStore.getInitialState(), | 475 | useComposerDraftStore.getInitialState(), |
| 440 | ); | 476 | ); |
| 441 | 477 | ||
| 442 | expect(mergedState.draftsByThreadId[threadId]).toBeUndefined(); | 478 | expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined(); |
| 443 | expect(mergedState.draftThreadsByThreadId).toEqual({}); | 479 | expect(mergedState.draftThreadsByThreadKey).toEqual({}); |
| 444 | expect(mergedState.projectDraftThreadIdByProjectId).toEqual({}); | 480 | expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({}); |
| 445 | }); | 481 | }); |
| 446 | }); | 482 | }); |
| 447 | 483 | ||
| 448 | describe("composerDraftStore project draft thread mapping", () => { | 484 | describe("composerDraftStore project draft thread mapping", () => { |
| 449 | const projectId = ProjectId.makeUnsafe("project-a"); | 485 | const projectId = ProjectId.makeUnsafe("project-a"); |
| 450 | const otherProjectId = ProjectId.makeUnsafe("project-b"); | 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 | const threadId = ThreadId.makeUnsafe("thread-a"); | 490 | const threadId = ThreadId.makeUnsafe("thread-a"); |
| 452 | const otherThreadId = ThreadId.makeUnsafe("thread-b"); | 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 | beforeEach(() => { | 498 | beforeEach(() => { |
| 455 | resetComposerDraftStore(); | 499 | resetComposerDraftStore(); |
| ... | @@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 457 | 501 | ||
| 458 | it("stores and reads project draft thread ids via actions", () => { | 502 | it("stores and reads project draft thread ids via actions", () => { |
| 459 | const store = useComposerDraftStore.getState(); | 503 | const store = useComposerDraftStore.getState(); |
| 460 | expect(store.getDraftThreadByProjectId(projectId)).toBeNull(); | 504 | expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); |
| 461 | expect(store.getDraftThread(threadId)).toBeNull(); | 505 | expect(store.getDraftThread(draftId)).toBeNull(); |
| 462 | 506 | ||
| 463 | store.setProjectDraftThreadId(projectId, threadId, { | 507 | store.setProjectDraftThreadId(projectRef, draftId, { |
| 508 | threadId, | ||
| 464 | branch: "feature/test", | 509 | branch: "feature/test", |
| 465 | worktreePath: "/tmp/worktree-test", | 510 | worktreePath: "/tmp/worktree-test", |
| 466 | createdAt: "2026-01-01T00:00:00.000Z", | 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 | threadId, | 514 | threadId, |
| 515 | environmentId: TEST_ENVIRONMENT_ID, | ||
| 470 | projectId, | 516 | projectId, |
| 517 | logicalProjectKey: scopedProjectKey(projectRef), | ||
| 471 | branch: "feature/test", | 518 | branch: "feature/test", |
| 472 | worktreePath: "/tmp/worktree-test", | 519 | worktreePath: "/tmp/worktree-test", |
| 473 | envMode: "worktree", | 520 | envMode: "worktree", |
| ... | @@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 475 | interactionMode: "default", | 522 | interactionMode: "default", |
| 476 | createdAt: "2026-01-01T00:00:00.000Z", | 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 | projectId, | 527 | projectId, |
| 528 | logicalProjectKey: scopedProjectKey(projectRef), | ||
| 480 | branch: "feature/test", | 529 | branch: "feature/test", |
| 481 | worktreePath: "/tmp/worktree-test", | 530 | worktreePath: "/tmp/worktree-test", |
| 482 | envMode: "worktree", | 531 | envMode: "worktree", |
| ... | @@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 488 | 537 | ||
| 489 | it("clears only matching project draft mapping entries", () => { | 538 | it("clears only matching project draft mapping entries", () => { |
| 490 | const store = useComposerDraftStore.getState(); | 539 | const store = useComposerDraftStore.getState(); |
| 491 | store.setProjectDraftThreadId(projectId, threadId); | 540 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
| 492 | store.setPrompt(threadId, "hello"); | 541 | store.setPrompt(draftId, "hello"); |
| 493 | 542 | ||
| 494 | store.clearProjectDraftThreadById(projectId, otherThreadId); | 543 | store.clearProjectDraftThreadById(projectRef, otherDraftId); |
| 495 | expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( | 544 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( |
| 496 | threadId, | 545 | threadId, |
| 497 | ); | 546 | ); |
| 498 | 547 | ||
| 499 | store.clearProjectDraftThreadById(projectId, threadId); | 548 | store.clearProjectDraftThreadById(projectRef, draftId); |
| 500 | expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | 549 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); |
| 501 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | 550 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); |
| 502 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | 551 | expect(draftByKey(draftId)).toBeUndefined(); |
| 503 | }); | 552 | }); |
| 504 | 553 | ||
| 505 | it("clears project draft mapping by project id", () => { | 554 | it("clears project draft mapping by project id", () => { |
| 506 | const store = useComposerDraftStore.getState(); | 555 | const store = useComposerDraftStore.getState(); |
| 507 | store.setProjectDraftThreadId(projectId, threadId); | 556 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
| 508 | store.setPrompt(threadId, "hello"); | 557 | store.setPrompt(draftId, "hello"); |
| 509 | store.clearProjectDraftThreadId(projectId); | 558 | store.clearProjectDraftThreadId(projectRef); |
| 510 | expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | 559 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); |
| 511 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | 560 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); |
| 512 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | 561 | expect(draftByKey(draftId)).toBeUndefined(); |
| 513 | }); | 562 | }); |
| 514 | 563 | ||
| 515 | it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { | 564 | it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { |
| 516 | const store = useComposerDraftStore.getState(); | 565 | const store = useComposerDraftStore.getState(); |
| 517 | store.setProjectDraftThreadId(projectId, threadId); | 566 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
| 518 | store.setPrompt(threadId, "orphan me"); | 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 | otherThreadId, | 572 | otherThreadId, |
| 524 | ); | 573 | ); |
| 525 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | 574 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); |
| 526 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | 575 | expect(draftByKey(draftId)).toBeUndefined(); |
| 527 | }); | 576 | }); |
| 528 | 577 | ||
| 529 | it("keeps composer drafts when the thread is still mapped by another project", () => { | 578 | it("keeps composer drafts when the thread is still mapped by another project", () => { |
| 530 | const store = useComposerDraftStore.getState(); | 579 | const store = useComposerDraftStore.getState(); |
| 531 | store.setProjectDraftThreadId(projectId, threadId); | 580 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
| 532 | store.setProjectDraftThreadId(otherProjectId, threadId); | 581 | store.setProjectDraftThreadId(otherProjectRef, sharedDraftId, { threadId }); |
| 533 | store.setPrompt(threadId, "keep me"); | 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 | expect( | 587 | expect( |
| 539 | useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, | 588 | useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId, |
| 540 | ).toBe(threadId); | 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 | it("clears draft registration independently", () => { | 593 | it("clears draft registration independently", () => { |
| 545 | const store = useComposerDraftStore.getState(); | 594 | const store = useComposerDraftStore.getState(); |
| 546 | store.setProjectDraftThreadId(projectId, threadId); | 595 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
| 547 | store.setPrompt(threadId, "remove me"); | 596 | store.setPrompt(draftId, "remove me"); |
| 548 | store.clearDraftThread(threadId); | 597 | store.clearDraftThread(draftId); |
| 549 | expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); | 598 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); |
| 550 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | 599 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); |
| 551 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | 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 | const store = useComposerDraftStore.getState(); | 618 | const store = useComposerDraftStore.getState(); |
| 556 | store.setProjectDraftThreadId(projectId, threadId); | 619 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); |
| 557 | store.setPrompt(threadId, "promote me"); | ||
| 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(); | 624 | expect(store.getComposerDraft(draftId)?.prompt).toBe("scoped access"); |
| 562 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | 625 | expect(store.getComposerDraft(threadRef)?.prompt).toBe("scoped access"); |
| 563 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | ||
| 564 | }); | 626 | }); |
| 565 | 627 | ||
| 566 | it("does not clear composer drafts for existing server threads during promotion cleanup", () => { | 628 | it("does not clear composer drafts for existing server threads during promotion cleanup", () => { |
| 567 | const store = useComposerDraftStore.getState(); | 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(); | 635 | expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); |
| 573 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); | 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 | const store = useComposerDraftStore.getState(); | 640 | const store = useComposerDraftStore.getState(); |
| 578 | store.setProjectDraftThreadId(projectId, threadId); | 641 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
| 579 | store.setPrompt(threadId, "promote me"); | 642 | store.setPrompt(draftId, "promote me"); |
| 580 | store.setProjectDraftThreadId(otherProjectId, otherThreadId); | 643 | store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId }); |
| 581 | store.setPrompt(otherThreadId, "keep me"); | 644 | store.setPrompt(otherDraftId, "keep me"); |
| 582 | 645 | ||
| 583 | clearPromotedDraftThreads([threadId]); | 646 | markPromotedDraftThreads([threadId]); |
| 584 | 647 | ||
| 585 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); | 648 | expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( |
| 586 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | 649 | scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), |
| 650 | ); | ||
| 651 | expect(draftByKey(draftId)?.prompt).toBe("promote me"); | ||
| 587 | expect( | 652 | expect( |
| 588 | useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, | 653 | useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId, |
| 589 | ).toBe(otherThreadId); | 654 | ).toBe(otherThreadId); |
| 590 | expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe( | 655 | expect(draftByKey(otherDraftId)?.prompt).toBe("keep me"); |
| 591 | "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 | it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => { | 704 | it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => { |
| 596 | const store = useComposerDraftStore.getState(); | 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(); | 711 | expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); |
| 602 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); | 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 | it("updates branch context on an existing draft thread", () => { | 728 | it("updates branch context on an existing draft thread", () => { |
| 606 | const store = useComposerDraftStore.getState(); | 729 | const store = useComposerDraftStore.getState(); |
| 607 | store.setProjectDraftThreadId(projectId, threadId, { | 730 | store.setProjectDraftThreadId(projectRef, draftId, { |
| 731 | threadId, | ||
| 608 | branch: "main", | 732 | branch: "main", |
| 609 | worktreePath: null, | 733 | worktreePath: null, |
| 610 | }); | 734 | }); |
| 611 | store.setDraftThreadContext(threadId, { | 735 | store.setDraftThreadContext(draftId, { |
| 612 | branch: "feature/next", | 736 | branch: "feature/next", |
| 613 | worktreePath: "/tmp/feature-next", | 737 | worktreePath: "/tmp/feature-next", |
| 614 | }); | 738 | }); |
| 615 | expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( | 739 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( |
| 616 | threadId, | 740 | threadId, |
| 617 | ); | 741 | ); |
| 618 | expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ | 742 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ |
| 743 | environmentId: TEST_ENVIRONMENT_ID, | ||
| 619 | projectId, | 744 | projectId, |
| 620 | branch: "feature/next", | 745 | branch: "feature/next", |
| 621 | worktreePath: "/tmp/feature-next", | 746 | worktreePath: "/tmp/feature-next", |
| ... | @@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 625 | 750 | ||
| 626 | it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { | 751 | it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { |
| 627 | const store = useComposerDraftStore.getState(); | 752 | const store = useComposerDraftStore.getState(); |
| 628 | store.setProjectDraftThreadId(projectId, threadId, { | 753 | store.setProjectDraftThreadId(projectRef, draftId, { |
| 754 | threadId, | ||
| 629 | branch: "main", | 755 | branch: "main", |
| 630 | worktreePath: "/tmp/main-worktree", | 756 | worktreePath: "/tmp/main-worktree", |
| 631 | }); | 757 | }); |
| ... | @@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 636 | branch?: string | null; | 762 | branch?: string | null; |
| 637 | worktreePath?: string | null; | 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 | projectId, | 769 | projectId, |
| 643 | branch: "main", | 770 | branch: "main", |
| 644 | worktreePath: "/tmp/main-worktree", | 771 | worktreePath: "/tmp/main-worktree", |
| ... | @@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 648 | 775 | ||
| 649 | it("preserves worktree env mode without a worktree path", () => { | 776 | it("preserves worktree env mode without a worktree path", () => { |
| 650 | const store = useComposerDraftStore.getState(); | 777 | const store = useComposerDraftStore.getState(); |
| 651 | store.setProjectDraftThreadId(projectId, threadId, { | 778 | store.setProjectDraftThreadId(projectRef, draftId, { |
| 779 | threadId, | ||
| 652 | branch: "feature/base", | 780 | branch: "feature/base", |
| 653 | worktreePath: null, | 781 | worktreePath: null, |
| 654 | envMode: "worktree", | 782 | envMode: "worktree", |
| ... | @@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 662 | worktreePath?: string | null; | 790 | worktreePath?: string | null; |
| 663 | envMode?: "local" | "worktree"; | 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 | projectId, | 797 | projectId, |
| 669 | branch: "feature/base", | 798 | branch: "feature/base", |
| 670 | worktreePath: null, | 799 | worktreePath: null, |
| ... | @@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 675 | 804 | ||
| 676 | describe("composerDraftStore modelSelection", () => { | 805 | describe("composerDraftStore modelSelection", () => { |
| 677 | const threadId = ThreadId.makeUnsafe("thread-model-options"); | 806 | const threadId = ThreadId.makeUnsafe("thread-model-options"); |
| 807 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 678 | 808 | ||
| 679 | beforeEach(() => { | 809 | beforeEach(() => { |
| 680 | resetComposerDraftStore(); | 810 | resetComposerDraftStore(); |
| ... | @@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => { |
| 683 | it("stores a model selection in the draft", () => { | 813 | it("stores a model selection in the draft", () => { |
| 684 | const store = useComposerDraftStore.getState(); | 814 | const store = useComposerDraftStore.getState(); |
| 685 | store.setModelSelection( | 815 | store.setModelSelection( |
| 686 | threadId, | 816 | threadRef, |
| 687 | modelSelection("codex", "gpt-5.3-codex", { | 817 | modelSelection("codex", "gpt-5.3-codex", { |
| 688 | reasoningEffort: "xhigh", | 818 | reasoningEffort: "xhigh", |
| 689 | fastMode: true, | 819 | fastMode: true, |
| 690 | }), | 820 | }), |
| 691 | ); | 821 | ); |
| 692 | 822 | ||
| 693 | expect( | 823 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( |
| 694 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, | ||
| 695 | ).toEqual( | ||
| 696 | modelSelection("codex", "gpt-5.3-codex", { | 824 | modelSelection("codex", "gpt-5.3-codex", { |
| 697 | reasoningEffort: "xhigh", | 825 | reasoningEffort: "xhigh", |
| 698 | fastMode: true, | 826 | fastMode: true, |
| ... | @@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => { |
| 702 | 830 | ||
| 703 | it("keeps default-only model selections on the draft", () => { | 831 | it("keeps default-only model selections on the draft", () => { |
| 704 | const store = useComposerDraftStore.getState(); | 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( | 835 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( |
| 708 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, | 836 | modelSelection("codex", "gpt-5.4"), |
| 709 | ).toEqual(modelSelection("codex", "gpt-5.4")); | 837 | ); |
| 710 | }); | 838 | }); |
| 711 | 839 | ||
| 712 | it("replaces only the targeted provider options on the current model selection", () => { | 840 | it("replaces only the targeted provider options on the current model selection", () => { |
| 713 | const store = useComposerDraftStore.getState(); | 841 | const store = useComposerDraftStore.getState(); |
| 714 | 842 | ||
| 715 | store.setModelSelection( | 843 | store.setModelSelection( |
| 716 | threadId, | 844 | threadRef, |
| 717 | modelSelection("claudeAgent", "claude-opus-4-6", { | 845 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 718 | effort: "max", | 846 | effort: "max", |
| 719 | fastMode: true, | 847 | fastMode: true, |
| ... | @@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => { |
| 727 | ); | 855 | ); |
| 728 | 856 | ||
| 729 | store.setProviderModelOptions( | 857 | store.setProviderModelOptions( |
| 730 | threadId, | 858 | threadRef, |
| 731 | "claudeAgent", | 859 | "claudeAgent", |
| 732 | { | 860 | { |
| 733 | thinking: false, | 861 | thinking: false, |
| ... | @@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => { |
| 735 | { persistSticky: true }, | 863 | { persistSticky: true }, |
| 736 | ); | 864 | ); |
| 737 | 865 | ||
| 738 | expect( | 866 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( |
| 739 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider | ||
| 740 | .claudeAgent, | ||
| 741 | ).toEqual( | ||
| 742 | modelSelection("claudeAgent", "claude-opus-4-6", { | 867 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 743 | thinking: false, | 868 | thinking: false, |
| 744 | }), | 869 | }), |
| ... | @@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => { |
| 754 | const store = useComposerDraftStore.getState(); | 879 | const store = useComposerDraftStore.getState(); |
| 755 | 880 | ||
| 756 | store.setModelSelection( | 881 | store.setModelSelection( |
| 757 | threadId, | 882 | threadRef, |
| 758 | modelSelection("claudeAgent", "claude-opus-4-6", { | 883 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 759 | effort: "max", | 884 | effort: "max", |
| 760 | }), | 885 | }), |
| 761 | ); | 886 | ); |
| 762 | 887 | ||
| 763 | store.setProviderModelOptions(threadId, "claudeAgent", { | 888 | store.setProviderModelOptions(threadRef, "claudeAgent", { |
| 764 | thinking: true, | 889 | thinking: true, |
| 765 | }); | 890 | }); |
| 766 | 891 | ||
| 767 | expect( | 892 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( |
| 768 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider | ||
| 769 | .claudeAgent, | ||
| 770 | ).toEqual( | ||
| 771 | modelSelection("claudeAgent", "claude-opus-4-6", { | 893 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 772 | thinking: true, | 894 | thinking: true, |
| 773 | }), | 895 | }), |
| ... | @@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => { |
| 778 | it("keeps explicit off/default codex overrides on the selection", () => { | 900 | it("keeps explicit off/default codex overrides on the selection", () => { |
| 779 | const store = useComposerDraftStore.getState(); | 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 | reasoningEffort: "high", | 906 | reasoningEffort: "high", |
| 785 | fastMode: false, | 907 | fastMode: false, |
| 786 | }); | 908 | }); |
| 787 | 909 | ||
| 788 | expect( | 910 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( |
| 789 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, | ||
| 790 | ).toEqual( | ||
| 791 | modelSelection("codex", "gpt-5.4", { | 911 | modelSelection("codex", "gpt-5.4", { |
| 792 | reasoningEffort: "high", | 912 | reasoningEffort: "high", |
| 793 | fastMode: false, | 913 | fastMode: false, |
| ... | @@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => { |
| 802 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 922 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 803 | ); | 923 | ); |
| 804 | store.setModelSelection( | 924 | store.setModelSelection( |
| 805 | threadId, | 925 | threadRef, |
| 806 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 926 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 807 | ); | 927 | ); |
| 808 | 928 | ||
| 809 | store.setProviderModelOptions(threadId, "claudeAgent", { | 929 | store.setProviderModelOptions(threadRef, "claudeAgent", { |
| 810 | thinking: false, | 930 | thinking: false, |
| 811 | }); | 931 | }); |
| 812 | 932 | ||
| 813 | expect( | 933 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( |
| 814 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider | ||
| 815 | .claudeAgent, | ||
| 816 | ).toEqual( | ||
| 817 | modelSelection("claudeAgent", "claude-opus-4-6", { | 934 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 818 | thinking: false, | 935 | thinking: false, |
| 819 | }), | 936 | }), |
| ... | @@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => { |
| 828 | 945 | ||
| 829 | // Set options for both providers | 946 | // Set options for both providers |
| 830 | store.setModelOptions( | 947 | store.setModelOptions( |
| 831 | threadId, | 948 | threadRef, |
| 832 | providerModelOptions({ | 949 | providerModelOptions({ |
| 833 | codex: { fastMode: true }, | 950 | codex: { fastMode: true }, |
| 834 | claudeAgent: { effort: "max" }, | 951 | claudeAgent: { effort: "max" }, |
| ... | @@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => { |
| 836 | ); | 953 | ); |
| 837 | 954 | ||
| 838 | // Now set options for only codex — claudeAgent should be untouched | 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 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); | 959 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); |
| 843 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); | 960 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); |
| 844 | }); | 961 | }); |
| ... | @@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => { |
| 847 | const store = useComposerDraftStore.getState(); | 964 | const store = useComposerDraftStore.getState(); |
| 848 | 965 | ||
| 849 | store.setModelOptions( | 966 | store.setModelOptions( |
| 850 | threadId, | 967 | threadRef, |
| 851 | providerModelOptions({ | 968 | providerModelOptions({ |
| 852 | codex: { fastMode: true }, | 969 | codex: { fastMode: true }, |
| 853 | claudeAgent: { effort: "max" }, | 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 | expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( | 977 | expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( |
| 861 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 978 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 862 | ); | 979 | ); |
| ... | @@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => { |
| 867 | it("creates the first sticky snapshot from provider option changes", () => { | 984 | it("creates the first sticky snapshot from provider option changes", () => { |
| 868 | const store = useComposerDraftStore.getState(); | 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 | store.setProviderModelOptions( | 989 | store.setProviderModelOptions( |
| 873 | threadId, | 990 | threadRef, |
| 874 | "codex", | 991 | "codex", |
| 875 | { | 992 | { |
| 876 | fastMode: true, | 993 | fastMode: true, |
| ... | @@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => { |
| 892 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1009 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 893 | ); | 1010 | ); |
| 894 | store.setModelSelection( | 1011 | store.setModelSelection( |
| 895 | threadId, | 1012 | threadRef, |
| 896 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | 1013 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 897 | ); | 1014 | ); |
| 898 | 1015 | ||
| 899 | store.setProviderModelOptions( | 1016 | store.setProviderModelOptions( |
| 900 | threadId, | 1017 | threadRef, |
| 901 | "claudeAgent", | 1018 | "claudeAgent", |
| 902 | { | 1019 | { |
| 903 | thinking: false, | 1020 | thinking: false, |
| ... | @@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => { |
| 905 | { persistSticky: false }, | 1022 | { persistSticky: false }, |
| 906 | ); | 1023 | ); |
| 907 | 1024 | ||
| 908 | expect( | 1025 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( |
| 909 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider | ||
| 910 | .claudeAgent, | ||
| 911 | ).toEqual( | ||
| 912 | modelSelection("claudeAgent", "claude-opus-4-6", { | 1026 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 913 | thinking: false, | 1027 | thinking: false, |
| 914 | }), | 1028 | }), |
| ... | @@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => { |
| 921 | 1035 | ||
| 922 | describe("composerDraftStore setModelSelection", () => { | 1036 | describe("composerDraftStore setModelSelection", () => { |
| 923 | const threadId = ThreadId.makeUnsafe("thread-model"); | 1037 | const threadId = ThreadId.makeUnsafe("thread-model"); |
| 1038 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 924 | 1039 | ||
| 925 | beforeEach(() => { | 1040 | beforeEach(() => { |
| 926 | resetComposerDraftStore(); | 1041 | resetComposerDraftStore(); |
| ... | @@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => { | ... | @@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => { |
| 929 | it("keeps explicit model overrides instead of coercing to null", () => { | 1044 | it("keeps explicit model overrides instead of coercing to null", () => { |
| 930 | const store = useComposerDraftStore.getState(); | 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( | 1049 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( |
| 935 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, | 1050 | modelSelection("codex", "gpt-5.3-codex"), |
| 936 | ).toEqual(modelSelection("codex", "gpt-5.3-codex")); | 1051 | ); |
| 937 | }); | 1052 | }); |
| 938 | }); | 1053 | }); |
| 939 | 1054 | ||
| ... | @@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => { |
| 975 | it("applies sticky activeProvider to new drafts", () => { | 1090 | it("applies sticky activeProvider to new drafts", () => { |
| 976 | const store = useComposerDraftStore.getState(); | 1091 | const store = useComposerDraftStore.getState(); |
| 977 | const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider"); | 1092 | const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider"); |
| 1093 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 978 | 1094 | ||
| 979 | store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6")); | 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 | modelSelectionByProvider: { | 1099 | modelSelectionByProvider: { |
| 984 | claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"), | 1100 | claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"), |
| 985 | }, | 1101 | }, |
| ... | @@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => { |
| 990 | 1106 | ||
| 991 | describe("composerDraftStore provider-scoped option updates", () => { | 1107 | describe("composerDraftStore provider-scoped option updates", () => { |
| 992 | const threadId = ThreadId.makeUnsafe("thread-provider"); | 1108 | const threadId = ThreadId.makeUnsafe("thread-provider"); |
| 1109 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 993 | 1110 | ||
| 994 | beforeEach(() => { | 1111 | beforeEach(() => { |
| 995 | resetComposerDraftStore(); | 1112 | resetComposerDraftStore(); |
| ... | @@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => { | ... | @@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => { |
| 998 | it("retains off-provider option memory without changing the active selection", () => { | 1115 | it("retains off-provider option memory without changing the active selection", () => { |
| 999 | const store = useComposerDraftStore.getState(); | 1116 | const store = useComposerDraftStore.getState(); |
| 1000 | store.setModelSelection( | 1117 | store.setModelSelection( |
| 1001 | threadId, | 1118 | threadRef, |
| 1002 | modelSelection("codex", "gpt-5.3-codex", { | 1119 | modelSelection("codex", "gpt-5.3-codex", { |
| 1003 | reasoningEffort: "medium", | 1120 | reasoningEffort: "medium", |
| 1004 | }), | 1121 | }), |
| 1005 | ); | 1122 | ); |
| 1006 | store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); | 1123 | store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" }); |
| 1007 | const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | 1124 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); |
| 1008 | expect(draft?.modelSelectionByProvider.codex).toEqual( | 1125 | expect(draft?.modelSelectionByProvider.codex).toEqual( |
| 1009 | modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), | 1126 | modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), |
| 1010 | ); | 1127 | ); |
| ... | @@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => { | ... | @@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => { |
| 1015 | 1132 | ||
| 1016 | describe("composerDraftStore runtime and interaction settings", () => { | 1133 | describe("composerDraftStore runtime and interaction settings", () => { |
| 1017 | const threadId = ThreadId.makeUnsafe("thread-settings"); | 1134 | const threadId = ThreadId.makeUnsafe("thread-settings"); |
| 1135 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); | ||
| 1018 | 1136 | ||
| 1019 | beforeEach(() => { | 1137 | beforeEach(() => { |
| 1020 | resetComposerDraftStore(); | 1138 | resetComposerDraftStore(); |
| ... | @@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => { | ... | @@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => { |
| 1023 | it("stores runtime mode overrides in the composer draft", () => { | 1141 | it("stores runtime mode overrides in the composer draft", () => { |
| 1024 | const store = useComposerDraftStore.getState(); | 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( | 1146 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.runtimeMode).toBe("approval-required"); |
| 1029 | "approval-required", | ||
| 1030 | ); | ||
| 1031 | }); | 1147 | }); |
| 1032 | 1148 | ||
| 1033 | it("stores interaction mode overrides in the composer draft", () => { | 1149 | it("stores interaction mode overrides in the composer draft", () => { |
| 1034 | const store = useComposerDraftStore.getState(); | 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( | 1154 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan"); |
| 1039 | "plan", | ||
| 1040 | ); | ||
| 1041 | }); | 1155 | }); |
| 1042 | 1156 | ||
| 1043 | it("removes empty settings-only drafts when overrides are cleared", () => { | 1157 | it("removes empty settings-only drafts when overrides are cleared", () => { |
| 1044 | const store = useComposerDraftStore.getState(); | 1158 | const store = useComposerDraftStore.getState(); |
| 1045 | 1159 | ||
| 1046 | store.setRuntimeMode(threadId, "approval-required"); | 1160 | store.setRuntimeMode(threadRef, "approval-required"); |
| 1047 | store.setInteractionMode(threadId, "plan"); | 1161 | store.setInteractionMode(threadRef, "plan"); |
| 1048 | store.setRuntimeMode(threadId, null); | 1162 | store.setRuntimeMode(threadRef, null); |
| 1049 | store.setInteractionMode(threadId, 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,6 +3,7 @@ import { |
| 3 | type ClaudeCodeEffort, | 3 | type ClaudeCodeEffort, |
| 4 | type CodexReasoningEffort, | 4 | type CodexReasoningEffort, |
| 5 | DEFAULT_MODEL_BY_PROVIDER, | 5 | DEFAULT_MODEL_BY_PROVIDER, |
| 6 | type EnvironmentId, | ||
| 6 | ModelSelection, | 7 | ModelSelection, |
| 7 | ProjectId, | 8 | ProjectId, |
| 8 | ProviderInteractionMode, | 9 | ProviderInteractionMode, |
| ... | @@ -10,8 +11,18 @@ import { | ... | @@ -10,8 +11,18 @@ import { |
| 10 | ProviderModelOptions, | 11 | ProviderModelOptions, |
| 11 | RuntimeMode, | 12 | RuntimeMode, |
| 12 | type ServerProvider, | 13 | type ServerProvider, |
| 14 | type ScopedProjectRef, | ||
| 15 | type ScopedThreadRef, | ||
| 13 | ThreadId, | 16 | ThreadId, |
| 14 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 18 | import { | ||
| 19 | parseScopedProjectKey, | ||
| 20 | parseScopedThreadKey, | ||
| 21 | scopedProjectKey, | ||
| 22 | scopeProjectRef, | ||
| 23 | scopedThreadKey, | ||
| 24 | scopeThreadRef, | ||
| 25 | } from "@t3tools/client-runtime"; | ||
| 15 | import * as Schema from "effect/Schema"; | 26 | import * as Schema from "effect/Schema"; |
| 16 | import * as Equal from "effect/Equal"; | 27 | import * as Equal from "effect/Equal"; |
| 17 | import { DeepMutable } from "effect/Types"; | 28 | import { DeepMutable } from "effect/Types"; |
| ... | @@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels"; | ... | @@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels"; |
| 32 | import { UnifiedSettings } from "@t3tools/contracts/settings"; | 43 | import { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 33 | 44 | ||
| 34 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; | 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 | const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); | 47 | const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); |
| 37 | export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; | 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 | const COMPOSER_PERSIST_DEBOUNCE_MS = 300; | 53 | const COMPOSER_PERSIST_DEBOUNCE_MS = 300; |
| 40 | 54 | ||
| 41 | const composerDebouncedStorage = createDebouncedStorage( | 55 | const composerDebouncedStorage = createDebouncedStorage( |
| ... | @@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage( | ... | @@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage( |
| 44 | ); | 58 | ); |
| 45 | 59 | ||
| 46 | // Flush pending composer draft writes before page unload to prevent data loss. | 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 | window.addEventListener("beforeunload", () => { | 62 | window.addEventListener("beforeunload", () => { |
| 49 | composerDebouncedStorage.flush(); | 63 | composerDebouncedStorage.flush(); |
| 50 | }); | 64 | }); |
| ... | @@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; | ... | @@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; |
| 122 | type LegacyV2StoreFields = { | 136 | type LegacyV2StoreFields = { |
| 123 | stickyModelSelection?: ModelSelection | null; | 137 | stickyModelSelection?: ModelSelection | null; |
| 124 | stickyModelOptions?: ProviderModelOptions | null; | 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 | type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & | 149 | type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & |
| ... | @@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & | ... | @@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & |
| 129 | LegacyV2StoreFields; | 151 | LegacyV2StoreFields; |
| 130 | 152 | ||
| 131 | const PersistedDraftThreadState = Schema.Struct({ | 153 | const PersistedDraftThreadState = Schema.Struct({ |
| 154 | threadId: ThreadId, | ||
| 155 | environmentId: Schema.String, | ||
| 132 | projectId: ProjectId, | 156 | projectId: ProjectId, |
| 157 | logicalProjectKey: Schema.optionalKey(Schema.String), | ||
| 133 | createdAt: Schema.String, | 158 | createdAt: Schema.String, |
| 134 | runtimeMode: RuntimeMode, | 159 | runtimeMode: RuntimeMode, |
| 135 | interactionMode: ProviderInteractionMode, | 160 | interactionMode: ProviderInteractionMode, |
| 136 | branch: Schema.NullOr(Schema.String), | 161 | branch: Schema.NullOr(Schema.String), |
| 137 | worktreePath: Schema.NullOr(Schema.String), | 162 | worktreePath: Schema.NullOr(Schema.String), |
| 138 | envMode: DraftThreadEnvModeSchema, | 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 | type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; | 173 | type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; |
| 141 | 174 | ||
| 142 | const PersistedComposerDraftStoreState = Schema.Struct({ | 175 | const PersistedComposerDraftStoreState = Schema.Struct({ |
| 143 | draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), | 176 | draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), |
| 144 | draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), | 177 | draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), |
| 145 | projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), | 178 | logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String), |
| 146 | stickyModelSelectionByProvider: Schema.optionalKey( | 179 | stickyModelSelectionByProvider: Schema.optionalKey( |
| 147 | Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), | 180 | Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), |
| 148 | ), | 181 | ), |
| ... | @@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ | ... | @@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ |
| 155 | state: PersistedComposerDraftStoreState, | 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 | export interface ComposerThreadDraftState { | 195 | export interface ComposerThreadDraftState { |
| 159 | prompt: string; | 196 | prompt: string; |
| 160 | images: ComposerImageAttachment[]; | 197 | images: ComposerImageAttachment[]; |
| ... | @@ -167,32 +204,94 @@ export interface ComposerThreadDraftState { | ... | @@ -167,32 +204,94 @@ export interface ComposerThreadDraftState { |
| 167 | interactionMode: ProviderInteractionMode | null; | 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 | projectId: ProjectId; | 216 | projectId: ProjectId; |
| 217 | logicalProjectKey: string; | ||
| 172 | createdAt: string; | 218 | createdAt: string; |
| 173 | runtimeMode: RuntimeMode; | 219 | runtimeMode: RuntimeMode; |
| 174 | interactionMode: ProviderInteractionMode; | 220 | interactionMode: ProviderInteractionMode; |
| 175 | branch: string | null; | 221 | branch: string | null; |
| 176 | worktreePath: string | null; | 222 | worktreePath: string | null; |
| 177 | envMode: DraftThreadEnvMode; | 223 | envMode: DraftThreadEnvMode; |
| 224 | promotedTo?: ScopedThreadRef | null; | ||
| 178 | } | 225 | } |
| 179 | 226 | ||
| 180 | interface ProjectDraftThread extends DraftThreadState { | 227 | export type DraftThreadState = DraftSessionState; |
| 181 | threadId: ThreadId; | 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 | interface ComposerDraftStoreState { | 253 | interface ComposerDraftStoreState { |
| 185 | draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; | 254 | draftsByThreadKey: Record<string, ComposerThreadDraftState>; |
| 186 | draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; | 255 | draftThreadsByThreadKey: Record<string, DraftThreadState>; |
| 187 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; | 256 | logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; |
| 188 | stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; | 257 | stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; |
| 189 | stickyActiveProvider: ProviderKind | null; | 258 | stickyActiveProvider: ProviderKind | null; |
| 190 | getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; | 259 | /** Returns the editable composer content for a draft session or server thread. */ |
| 191 | getDraftThread: (threadId: ThreadId) => DraftThreadState | null; | 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 | setProjectDraftThreadId: ( | 290 | setProjectDraftThreadId: ( |
| 193 | projectId: ProjectId, | 291 | projectRef: ScopedProjectRef, |
| 194 | threadId: ThreadId, | 292 | draftId: DraftId, |
| 195 | options?: { | 293 | options?: { |
| 294 | threadId?: ThreadId; | ||
| 196 | branch?: string | null; | 295 | branch?: string | null; |
| 197 | worktreePath?: string | null; | 296 | worktreePath?: string | null; |
| 198 | createdAt?: string; | 297 | createdAt?: string; |
| ... | @@ -201,65 +300,76 @@ interface ComposerDraftStoreState { | ... | @@ -201,65 +300,76 @@ interface ComposerDraftStoreState { |
| 201 | interactionMode?: ProviderInteractionMode; | 300 | interactionMode?: ProviderInteractionMode; |
| 202 | }, | 301 | }, |
| 203 | ) => void; | 302 | ) => void; |
| 303 | /** Updates mutable draft-session metadata without touching composer content. */ | ||
| 204 | setDraftThreadContext: ( | 304 | setDraftThreadContext: ( |
| 205 | threadId: ThreadId, | 305 | threadRef: ComposerThreadTarget, |
| 206 | options: { | 306 | options: { |
| 207 | branch?: string | null; | 307 | branch?: string | null; |
| 208 | worktreePath?: string | null; | 308 | worktreePath?: string | null; |
| 209 | projectId?: ProjectId; | 309 | projectRef?: ScopedProjectRef; |
| 210 | createdAt?: string; | 310 | createdAt?: string; |
| 211 | envMode?: DraftThreadEnvMode; | 311 | envMode?: DraftThreadEnvMode; |
| 212 | runtimeMode?: RuntimeMode; | 312 | runtimeMode?: RuntimeMode; |
| 213 | interactionMode?: ProviderInteractionMode; | 313 | interactionMode?: ProviderInteractionMode; |
| 214 | }, | 314 | }, |
| 215 | ) => void; | 315 | ) => void; |
| 216 | clearProjectDraftThreadId: (projectId: ProjectId) => void; | 316 | clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; |
| 217 | clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; | 317 | clearProjectDraftThreadById: ( |
| 218 | clearDraftThread: (threadId: ThreadId) => void; | 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 | setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; | 326 | setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; |
| 220 | setPrompt: (threadId: ThreadId, prompt: string) => void; | 327 | setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; |
| 221 | setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; | 328 | setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; |
| 222 | setModelSelection: ( | 329 | setModelSelection: ( |
| 223 | threadId: ThreadId, | 330 | threadRef: ComposerThreadTarget, |
| 224 | modelSelection: ModelSelection | null | undefined, | 331 | modelSelection: ModelSelection | null | undefined, |
| 225 | ) => void; | 332 | ) => void; |
| 226 | setModelOptions: ( | 333 | setModelOptions: ( |
| 227 | threadId: ThreadId, | 334 | threadRef: ComposerThreadTarget, |
| 228 | modelOptions: ProviderModelOptions | null | undefined, | 335 | modelOptions: ProviderModelOptions | null | undefined, |
| 229 | ) => void; | 336 | ) => void; |
| 230 | applyStickyState: (threadId: ThreadId) => void; | 337 | applyStickyState: (threadRef: ComposerThreadTarget) => void; |
| 231 | setProviderModelOptions: ( | 338 | setProviderModelOptions: ( |
| 232 | threadId: ThreadId, | 339 | threadRef: ComposerThreadTarget, |
| 233 | provider: ProviderKind, | 340 | provider: ProviderKind, |
| 234 | nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, | 341 | nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, |
| 235 | options?: { | 342 | options?: { |
| 236 | persistSticky?: boolean; | 343 | persistSticky?: boolean; |
| 237 | }, | 344 | }, |
| 238 | ) => void; | 345 | ) => void; |
| 239 | setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; | 346 | setRuntimeMode: ( |
| 347 | threadRef: ComposerThreadTarget, | ||
| 348 | runtimeMode: RuntimeMode | null | undefined, | ||
| 349 | ) => void; | ||
| 240 | setInteractionMode: ( | 350 | setInteractionMode: ( |
| 241 | threadId: ThreadId, | 351 | threadRef: ComposerThreadTarget, |
| 242 | interactionMode: ProviderInteractionMode | null | undefined, | 352 | interactionMode: ProviderInteractionMode | null | undefined, |
| 243 | ) => void; | 353 | ) => void; |
| 244 | addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; | 354 | addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; |
| 245 | addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; | 355 | addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; |
| 246 | removeImage: (threadId: ThreadId, imageId: string) => void; | 356 | removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; |
| 247 | insertTerminalContext: ( | 357 | insertTerminalContext: ( |
| 248 | threadId: ThreadId, | 358 | threadRef: ComposerThreadTarget, |
| 249 | prompt: string, | 359 | prompt: string, |
| 250 | context: TerminalContextDraft, | 360 | context: TerminalContextDraft, |
| 251 | index: number, | 361 | index: number, |
| 252 | ) => boolean; | 362 | ) => boolean; |
| 253 | addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void; | 363 | addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void; |
| 254 | addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; | 364 | addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; |
| 255 | removeTerminalContext: (threadId: ThreadId, contextId: string) => void; | 365 | removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; |
| 256 | clearTerminalContexts: (threadId: ThreadId) => void; | 366 | clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; |
| 257 | clearPersistedAttachments: (threadId: ThreadId) => void; | 367 | clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; |
| 258 | syncPersistedAttachments: ( | 368 | syncPersistedAttachments: ( |
| 259 | threadId: ThreadId, | 369 | threadRef: ComposerThreadTarget, |
| 260 | attachments: PersistedComposerImageAttachment[], | 370 | attachments: PersistedComposerImageAttachment[], |
| 261 | ) => void; | 371 | ) => void; |
| 262 | clearComposerContent: (threadId: ThreadId) => void; | 372 | clearComposerContent: (threadRef: ComposerThreadTarget) => void; |
| 263 | } | 373 | } |
| 264 | 374 | ||
| 265 | export interface EffectiveComposerModelState { | 375 | export interface EffectiveComposerModelState { |
| ... | @@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions( | ... | @@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions( |
| 293 | } | 403 | } |
| 294 | 404 | ||
| 295 | const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ | 405 | const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ |
| 296 | draftsByThreadId: {}, | 406 | draftsByThreadKey: {}, |
| 297 | draftThreadsByThreadId: {}, | 407 | draftThreadsByThreadKey: {}, |
| 298 | projectDraftThreadIdByProjectId: {}, | 408 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 299 | stickyModelSelectionByProvider: {}, | 409 | stickyModelSelectionByProvider: {}, |
| 300 | stickyActiveProvider: null, | 410 | stickyActiveProvider: null, |
| 301 | }); | 411 | }); |
| ... | @@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode( | ... | @@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode( |
| 748 | return fallbackWorktreePath ? "worktree" : "local"; | 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 | function normalizePersistedDraftThreads( | 1086 | function normalizePersistedDraftThreads( |
| 752 | rawDraftThreadsByThreadId: unknown, | 1087 | rawDraftThreadsByThreadId: unknown, |
| 753 | rawProjectDraftThreadIdByProjectId: unknown, | 1088 | rawProjectDraftThreadIdByProjectKey: unknown, |
| 754 | ): Pick< | 1089 | ): Pick< |
| 755 | PersistedComposerDraftStoreState, | 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 | if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { | 1117 | if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { |
| 760 | for (const [threadId, rawDraftThread] of Object.entries( | 1118 | for (const [threadKeyOrId, rawDraftThread] of Object.entries( |
| 761 | rawDraftThreadsByThreadId as Record<string, unknown>, | 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 | continue; | 1122 | continue; |
| 765 | } | 1123 | } |
| 766 | if (!rawDraftThread || typeof rawDraftThread !== "object") { | 1124 | if (!rawDraftThread || typeof rawDraftThread !== "object") { |
| 767 | continue; | 1125 | continue; |
| 768 | } | 1126 | } |
| 769 | const candidateDraftThread = rawDraftThread as Record<string, unknown>; | 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 | const projectId = candidateDraftThread.projectId; | 1142 | const projectId = candidateDraftThread.projectId; |
| 771 | const createdAt = candidateDraftThread.createdAt; | 1143 | const createdAt = candidateDraftThread.createdAt; |
| 772 | const branch = candidateDraftThread.branch; | 1144 | const branch = candidateDraftThread.branch; |
| 773 | const worktreePath = candidateDraftThread.worktreePath; | 1145 | const worktreePath = candidateDraftThread.worktreePath; |
| 774 | const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; | 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 | continue; | 1164 | continue; |
| 777 | } | 1165 | } |
| 778 | draftThreadsByThreadId[threadId as ThreadId] = { | 1166 | const normalizedEnvironmentId = environmentId as EnvironmentId; |
| 1167 | draftThreadsByThreadKey[threadKey] = { | ||
| 1168 | threadId, | ||
| 1169 | environmentId: normalizedEnvironmentId, | ||
| 779 | projectId: projectId as ProjectId, | 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 | createdAt: | 1178 | createdAt: |
| 781 | typeof createdAt === "string" && createdAt.length > 0 | 1179 | typeof createdAt === "string" && createdAt.length > 0 |
| 782 | ? createdAt | 1180 | ? createdAt |
| ... | @@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads( | ... | @@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads( |
| 794 | branch: typeof branch === "string" ? branch : null, | 1192 | branch: typeof branch === "string" ? branch : null, |
| 795 | worktreePath: normalizedWorktreePath, | 1193 | worktreePath: normalizedWorktreePath, |
| 796 | envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), | 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 | if ( | 1201 | if ( |
| 803 | rawProjectDraftThreadIdByProjectId && | 1202 | rawProjectDraftThreadIdByProjectKey && |
| 804 | typeof rawProjectDraftThreadIdByProjectId === "object" | 1203 | typeof rawProjectDraftThreadIdByProjectKey === "object" |
| 805 | ) { | 1204 | ) { |
| 806 | for (const [projectId, threadId] of Object.entries( | 1205 | for (const [logicalProjectKey, threadKeyOrId] of Object.entries( |
| 807 | rawProjectDraftThreadIdByProjectId as Record<string, unknown>, | 1206 | rawProjectDraftThreadIdByProjectKey as Record<string, unknown>, |
| 808 | )) { | 1207 | )) { |
| 809 | if ( | 1208 | if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { |
| 810 | typeof projectId === "string" && | 1209 | continue; |
| 811 | projectId.length > 0 && | 1210 | } |
| 812 | typeof threadId === "string" && | 1211 | const projectRef = parseScopedProjectKey(logicalProjectKey); |
| 813 | threadId.length > 0 | 1212 | const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); |
| 814 | ) { | 1213 | const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); |
| 815 | projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; | 1214 | logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey; |
| 816 | if (!draftThreadsByThreadId[threadId as ThreadId]) { | 1215 | if (parsedThreadRef) { |
| 817 | draftThreadsByThreadId[threadId as ThreadId] = { | 1216 | environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); |
| 818 | projectId: projectId as ProjectId, | 1217 | } |
| 819 | createdAt: new Date().toISOString(), | 1218 | if (!projectRef) { |
| 820 | runtimeMode: DEFAULT_RUNTIME_MODE, | 1219 | const existingDraftThread = draftThreadsByThreadKey[threadKey]; |
| 821 | interactionMode: DEFAULT_INTERACTION_MODE, | 1220 | if (existingDraftThread && !existingDraftThread.logicalProjectKey) { |
| 822 | branch: null, | 1221 | draftThreadsByThreadKey[threadKey] = { |
| 823 | worktreePath: null, | 1222 | ...existingDraftThread, |
| 824 | envMode: "local", | 1223 | logicalProjectKey, |
| 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, | ||
| 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 | function normalizePersistedDraftsByThreadId( | 1260 | function normalizePersistedDraftsByThreadId( |
| 840 | rawDraftMap: unknown, | 1261 | rawDraftMap: unknown, |
| 841 | ): PersistedComposerDraftStoreState["draftsByThreadId"] { | 1262 | draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"], |
| 1263 | ): PersistedComposerDraftStoreState["draftsByThreadKey"] { | ||
| 842 | if (!rawDraftMap || typeof rawDraftMap !== "object") { | 1264 | if (!rawDraftMap || typeof rawDraftMap !== "object") { |
| 843 | return {}; | 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>)) { | 1282 | for (const [threadKeyOrId, draftValue] of Object.entries( |
| 849 | if (typeof threadId !== "string" || threadId.length === 0) { | 1283 | rawDraftMap as Record<string, unknown>, |
| 1284 | )) { | ||
| 1285 | if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { | ||
| 850 | continue; | 1286 | continue; |
| 851 | } | 1287 | } |
| 852 | if (!draftValue || typeof draftValue !== "object") { | 1288 | if (!draftValue || typeof draftValue !== "object") { |
| ... | @@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId( |
| 937 | ) { | 1373 | ) { |
| 938 | continue; | 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 | prompt, | 1389 | prompt, |
| 942 | attachments, | 1390 | attachments, |
| 943 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), | 1391 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), |
| ... | @@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId( |
| 947 | }; | 1395 | }; |
| 948 | } | 1396 | } |
| 949 | 1397 | ||
| 950 | return nextDraftsByThreadId; | 1398 | return nextDraftsByThreadKey; |
| 951 | } | 1399 | } |
| 952 | 1400 | ||
| 953 | function migratePersistedComposerDraftStoreState( | 1401 | function migratePersistedComposerDraftStoreState( |
| ... | @@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState( | ... | @@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState( |
| 957 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; | 1405 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 958 | } | 1406 | } |
| 959 | const candidate = persistedState as LegacyPersistedComposerDraftStoreState; | 1407 | const candidate = persistedState as LegacyPersistedComposerDraftStoreState; |
| 960 | const rawDraftMap = candidate.draftsByThreadId; | 1408 | const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId; |
| 961 | const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; | 1409 | const rawDraftThreadsByThreadId = |
| 962 | const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; | 1410 | candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId; |
| 1411 | const rawProjectDraftThreadIdByProjectKey = | ||
| 1412 | candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ?? | ||
| 1413 | candidate.projectDraftThreadKeyByProjectKey ?? | ||
| 1414 | candidate.projectDraftThreadIdByProjectKey ?? | ||
| 1415 | candidate.projectDraftThreadIdByProjectId; | ||
| 963 | 1416 | ||
| 964 | // Migrate sticky state from v2 (dual) to v3 (consolidated) | 1417 | // Migrate sticky state from v2 (dual) to v3 (consolidated) |
| 965 | const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; | 1418 | const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; |
| ... | @@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState( | ... | @@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState( |
| 982 | ); | 1435 | ); |
| 983 | const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null; | 1436 | const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null; |
| 984 | 1437 | ||
| 985 | const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = | 1438 | const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = |
| 986 | normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); | 1439 | normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); |
| 987 | const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); | 1440 | const draftsByThreadKey = normalizePersistedDraftsByThreadId( |
| 1441 | rawDraftMap, | ||
| 1442 | draftThreadsByThreadKey, | ||
| 1443 | ); | ||
| 988 | return { | 1444 | return { |
| 989 | draftsByThreadId, | 1445 | draftsByThreadKey, |
| 990 | draftThreadsByThreadId, | 1446 | draftThreadsByThreadKey, |
| 991 | projectDraftThreadIdByProjectId, | 1447 | logicalProjectDraftThreadKeyByLogicalProjectKey, |
| 992 | stickyModelSelectionByProvider, | 1448 | stickyModelSelectionByProvider, |
| 993 | stickyActiveProvider, | 1449 | stickyActiveProvider, |
| 994 | }; | 1450 | }; |
| ... | @@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState( | ... | @@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState( |
| 997 | function partializeComposerDraftStoreState( | 1453 | function partializeComposerDraftStoreState( |
| 998 | state: ComposerDraftStoreState, | 1454 | state: ComposerDraftStoreState, |
| 999 | ): PersistedComposerDraftStoreState { | 1455 | ): PersistedComposerDraftStoreState { |
| 1000 | const persistedDraftsByThreadId: DeepMutable< | 1456 | const persistedDraftsByThreadKey: DeepMutable< |
| 1001 | PersistedComposerDraftStoreState["draftsByThreadId"] | 1457 | PersistedComposerDraftStoreState["draftsByThreadKey"] |
| 1002 | > = {}; | 1458 | > = {}; |
| 1003 | for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { | 1459 | for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) { |
| 1004 | if (typeof threadId !== "string" || threadId.length === 0) { | 1460 | if (typeof threadKey !== "string" || threadKey.length === 0) { |
| 1005 | continue; | 1461 | continue; |
| 1006 | } | 1462 | } |
| 1007 | const hasModelData = | 1463 | const hasModelData = |
| ... | @@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState( | ... | @@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState( |
| 1041 | ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), | 1497 | ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), |
| 1042 | ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), | 1498 | ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), |
| 1043 | }; | 1499 | }; |
| 1044 | persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; | 1500 | persistedDraftsByThreadKey[threadKey] = persistedDraft; |
| 1045 | } | 1501 | } |
| 1046 | return { | 1502 | return { |
| 1047 | draftsByThreadId: persistedDraftsByThreadId, | 1503 | draftsByThreadKey: persistedDraftsByThreadKey, |
| 1048 | draftThreadsByThreadId: state.draftThreadsByThreadId, | 1504 | draftThreadsByThreadKey: state.draftThreadsByThreadKey, |
| 1049 | projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, | 1505 | logicalProjectDraftThreadKeyByLogicalProjectKey: |
| 1506 | state.logicalProjectDraftThreadKeyByLogicalProjectKey, | ||
| 1050 | stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, | 1507 | stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, |
| 1051 | stickyActiveProvider: state.stickyActiveProvider, | 1508 | stickyActiveProvider: state.stickyActiveProvider, |
| 1052 | }; | 1509 | }; |
| ... | @@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState( | ... | @@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState( |
| 1059 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; | 1516 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 1060 | } | 1517 | } |
| 1061 | const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState; | 1518 | const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState; |
| 1062 | const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = | 1519 | const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = |
| 1063 | normalizePersistedDraftThreads( | 1520 | normalizePersistedDraftThreads( |
| 1064 | normalizedPersistedState.draftThreadsByThreadId, | 1521 | normalizedPersistedState.draftThreadsByThreadKey ?? |
| 1065 | normalizedPersistedState.projectDraftThreadIdByProjectId, | 1522 | normalizedPersistedState.draftThreadsByThreadId, |
| 1523 | normalizedPersistedState.logicalProjectDraftThreadKeyByLogicalProjectKey ?? | ||
| 1524 | normalizedPersistedState.projectDraftThreadKeyByProjectKey ?? | ||
| 1525 | normalizedPersistedState.projectDraftThreadIdByProjectKey ?? | ||
| 1526 | normalizedPersistedState.projectDraftThreadIdByProjectId, | ||
| 1066 | ); | 1527 | ); |
| 1067 | 1528 | ||
| 1068 | // Handle both v3 (modelSelectionByProvider) and v2/legacy formats | 1529 | // Handle both v3 (modelSelectionByProvider) and v2/legacy formats |
| ... | @@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( | ... | @@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( |
| 1105 | } | 1566 | } |
| 1106 | 1567 | ||
| 1107 | return { | 1568 | return { |
| 1108 | draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), | 1569 | draftsByThreadKey: normalizePersistedDraftsByThreadId( |
| 1109 | draftThreadsByThreadId, | 1570 | normalizedPersistedState.draftsByThreadKey ?? normalizedPersistedState.draftsByThreadId, |
| 1110 | projectDraftThreadIdByProjectId, | 1571 | draftThreadsByThreadKey, |
| 1572 | ), | ||
| 1573 | draftThreadsByThreadKey, | ||
| 1574 | logicalProjectDraftThreadKeyByLogicalProjectKey, | ||
| 1111 | stickyModelSelectionByProvider, | 1575 | stickyModelSelectionByProvider, |
| 1112 | stickyActiveProvider, | 1576 | stickyActiveProvider, |
| 1113 | }; | 1577 | }; |
| 1114 | } | 1578 | } |
| 1115 | 1579 | ||
| 1116 | function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { | 1580 | function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] { |
| 1117 | if (threadId.length === 0) { | 1581 | if (threadKey.length === 0) { |
| 1118 | return []; | 1582 | return []; |
| 1119 | } | 1583 | } |
| 1120 | try { | 1584 | try { |
| ... | @@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { | ... | @@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { |
| 1125 | if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) { | 1589 | if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) { |
| 1126 | return []; | 1590 | return []; |
| 1127 | } | 1591 | } |
| 1128 | return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map( | 1592 | return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map( |
| 1129 | (attachment) => attachment.id, | 1593 | (attachment) => attachment.id, |
| 1130 | ); | 1594 | ); |
| 1131 | } catch { | 1595 | } catch { |
| ... | @@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { | ... | @@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { |
| 1134 | } | 1598 | } |
| 1135 | 1599 | ||
| 1136 | function verifyPersistedAttachments( | 1600 | function verifyPersistedAttachments( |
| 1137 | threadId: ThreadId, | 1601 | threadKey: string, |
| 1138 | attachments: PersistedComposerImageAttachment[], | 1602 | attachments: PersistedComposerImageAttachment[], |
| 1139 | set: ( | 1603 | set: ( |
| 1140 | partial: | 1604 | partial: |
| ... | @@ -1149,12 +1613,12 @@ function verifyPersistedAttachments( | ... | @@ -1149,12 +1613,12 @@ function verifyPersistedAttachments( |
| 1149 | let persistedIdSet = new Set<string>(); | 1613 | let persistedIdSet = new Set<string>(); |
| 1150 | try { | 1614 | try { |
| 1151 | composerDebouncedStorage.flush(); | 1615 | composerDebouncedStorage.flush(); |
| 1152 | persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); | 1616 | persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey)); |
| 1153 | } catch { | 1617 | } catch { |
| 1154 | persistedIdSet = new Set(); | 1618 | persistedIdSet = new Set(); |
| 1155 | } | 1619 | } |
| 1156 | set((state) => { | 1620 | set((state) => { |
| 1157 | const current = state.draftsByThreadId[threadId]; | 1621 | const current = state.draftsByThreadKey[threadKey]; |
| 1158 | if (!current) { | 1622 | if (!current) { |
| 1159 | return state; | 1623 | return state; |
| 1160 | } | 1624 | } |
| ... | @@ -1170,13 +1634,13 @@ function verifyPersistedAttachments( | ... | @@ -1170,13 +1634,13 @@ function verifyPersistedAttachments( |
| 1170 | persistedAttachments, | 1634 | persistedAttachments, |
| 1171 | nonPersistedImageIds, | 1635 | nonPersistedImageIds, |
| 1172 | }; | 1636 | }; |
| 1173 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 1637 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 1174 | if (shouldRemoveDraft(nextDraft)) { | 1638 | if (shouldRemoveDraft(nextDraft)) { |
| 1175 | delete nextDraftsByThreadId[threadId]; | 1639 | delete nextDraftsByThreadKey[threadKey]; |
| 1176 | } else { | 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,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 | persist( | 1756 | persist( |
| 1263 | (set, get) => ({ | 1757 | (setBase, get) => { |
| 1264 | draftsByThreadId: {}, | 1758 | const set = setBase; |
| 1265 | draftThreadsByThreadId: {}, | 1759 | |
| 1266 | projectDraftThreadIdByProjectId: {}, | 1760 | return { |
| 1267 | stickyModelSelectionByProvider: {}, | 1761 | draftsByThreadKey: {}, |
| 1268 | stickyActiveProvider: null, | 1762 | draftThreadsByThreadKey: {}, |
| 1269 | getDraftThreadByProjectId: (projectId) => { | 1763 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 1270 | if (projectId.length === 0) { | 1764 | stickyModelSelectionByProvider: {}, |
| 1271 | return null; | 1765 | stickyActiveProvider: null, |
| 1272 | } | 1766 | getComposerDraft: (target) => getComposerDraftState(get(), target), |
| 1273 | const threadId = get().projectDraftThreadIdByProjectId[projectId]; | 1767 | getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { |
| 1274 | if (!threadId) { | 1768 | return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); |
| 1275 | return null; | 1769 | }, |
| 1276 | } | 1770 | getDraftSessionByLogicalProjectKey: (logicalProjectKey) => { |
| 1277 | const draftThread = get().draftThreadsByThreadId[threadId]; | 1771 | const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey); |
| 1278 | if (!draftThread || draftThread.projectId !== projectId) { | 1772 | if (normalizedLogicalProjectKey.length === 0) { |
| 1279 | return null; | 1773 | 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; | ||
| 1369 | } | 1774 | } |
| 1370 | const nextProjectId = options.projectId ?? existing.projectId; | 1775 | const draftId = |
| 1371 | if (nextProjectId.length === 0) { | 1776 | get().logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey]; |
| 1372 | return state; | 1777 | if (!draftId) { |
| 1778 | return null; | ||
| 1373 | } | 1779 | } |
| 1374 | const nextWorktreePath = | 1780 | const draftThread = get().draftThreadsByThreadKey[draftId]; |
| 1375 | options.worktreePath === undefined | 1781 | if (!draftThread || isDraftThreadPromoting(draftThread)) { |
| 1376 | ? existing.worktreePath | 1782 | return null; |
| 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; | ||
| 1401 | } | 1783 | } |
| 1402 | const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = { | 1784 | return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread); |
| 1403 | ...state.projectDraftThreadIdByProjectId, | 1785 | }, |
| 1404 | [nextProjectId]: threadId, | 1786 | getDraftThreadByProjectRef: (projectRef) => { |
| 1405 | }; | 1787 | return get().getDraftSessionByProjectRef(projectRef); |
| 1406 | if (existing.projectId !== nextProjectId) { | 1788 | }, |
| 1407 | if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) { | 1789 | getDraftSessionByProjectRef: (projectRef) => { |
| 1408 | delete nextProjectDraftThreadIdByProjectId[existing.projectId]; | 1790 | for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) { |
| 1791 | if (isDraftThreadPromoting(draftThread)) { | ||
| 1792 | continue; | ||
| 1409 | } | 1793 | } |
| 1410 | } | 1794 | if ( |
| 1411 | return { | 1795 | draftThread.projectId === projectRef.projectId && |
| 1412 | draftThreadsByThreadId: { | 1796 | draftThread.environmentId === projectRef.environmentId |
| 1413 | ...state.draftThreadsByThreadId, | 1797 | ) { |
| 1414 | [threadId]: nextDraftThread, | 1798 | return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread); |
| 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]; | ||
| 1441 | } | 1799 | } |
| 1442 | } | 1800 | } |
| 1443 | return { | 1801 | return null; |
| 1444 | draftsByThreadId: nextDraftsByThreadId, | 1802 | }, |
| 1445 | draftThreadsByThreadId: nextDraftThreadsByThreadId, | 1803 | getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null, |
| 1446 | projectDraftThreadIdByProjectId: restProjectMappings, | 1804 | getDraftSessionByRef: (threadRef) => { |
| 1447 | }; | 1805 | for (const draftSession of Object.values(get().draftThreadsByThreadKey)) { |
| 1448 | }); | 1806 | if ( |
| 1449 | }, | 1807 | draftSession.environmentId === threadRef.environmentId && |
| 1450 | clearProjectDraftThreadById: (projectId, threadId) => { | 1808 | draftSession.threadId === threadRef.threadId |
| 1451 | if (projectId.length === 0 || threadId.length === 0) { | 1809 | ) { |
| 1452 | return; | 1810 | return draftSession; |
| 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]; | ||
| 1470 | } | 1811 | } |
| 1471 | } | 1812 | } |
| 1472 | return { | 1813 | return null; |
| 1473 | draftsByThreadId: nextDraftsByThreadId, | 1814 | }, |
| 1474 | draftThreadsByThreadId: nextDraftThreadsByThreadId, | 1815 | getDraftThread: (threadRef) => { |
| 1475 | projectDraftThreadIdByProjectId: restProjectMappings, | 1816 | if (typeof threadRef === "string") { |
| 1476 | }; | 1817 | return get().getDraftSession(DraftId.makeUnsafe(threadRef)); |
| 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; | ||
| 1519 | } | 1818 | } |
| 1520 | const nextMap: Partial<Record<ProviderKind, ModelSelection>> = { | 1819 | return get().getDraftSessionByRef(threadRef); |
| 1521 | ...state.stickyModelSelectionByProvider, | 1820 | }, |
| 1522 | [normalized.provider]: normalized, | 1821 | getDraftThreadByRef: (threadRef) => { |
| 1523 | }; | 1822 | return get().getDraftSessionByRef(threadRef); |
| 1524 | if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { | 1823 | }, |
| 1525 | return state.stickyActiveProvider === normalized.provider | 1824 | listDraftThreadKeys: () => |
| 1526 | ? state | 1825 | Object.values(get().draftThreadsByThreadKey).map((draftThread) => |
| 1527 | : { stickyActiveProvider: normalized.provider }; | 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 { | 1837 | set((state) => { |
| 1530 | stickyModelSelectionByProvider: nextMap, | 1838 | const existingThread = state.draftThreadsByThreadKey[draftId]; |
| 1531 | stickyActiveProvider: normalized.provider, | 1839 | const previousThreadKeyForLogicalProject = |
| 1532 | }; | 1840 | state.logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey]; |
| 1533 | }); | 1841 | const nextDraftThread = createDraftThreadState( |
| 1534 | }, | 1842 | projectRef, |
| 1535 | applyStickyState: (threadId) => { | 1843 | options?.threadId ?? existingThread?.threadId ?? ThreadId.makeUnsafe(draftId), |
| 1536 | if (threadId.length === 0) { | 1844 | normalizedLogicalProjectKey, |
| 1537 | return; | 1845 | existingThread, |
| 1538 | } | 1846 | options, |
| 1539 | set((state) => { | 1847 | ); |
| 1540 | const stickyMap = state.stickyModelSelectionByProvider; | 1848 | const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; |
| 1541 | const stickyActiveProvider = state.stickyActiveProvider; | 1849 | if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) { |
| 1542 | if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { | 1850 | return state; |
| 1543 | 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]; | 1896 | set((state) => { |
| 1546 | const base = existing ?? createEmptyThreadDraft(); | 1897 | const existing = state.draftThreadsByThreadKey[threadKey]; |
| 1547 | const nextMap = { ...base.modelSelectionByProvider }; | 1898 | if (!existing) { |
| 1548 | for (const [provider, selection] of Object.entries(stickyMap)) { | 1899 | return state; |
| 1549 | if (selection) { | ||
| 1550 | const current = nextMap[provider as ProviderKind]; | ||
| 1551 | nextMap[provider as ProviderKind] = { | ||
| 1552 | ...selection, | ||
| 1553 | model: current?.model ?? selection.model, | ||
| 1554 | }; | ||
| 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 ( | 1972 | set((state) => { |
| 1558 | Equal.equals(base.modelSelectionByProvider, nextMap) && | 1973 | const draftThread = state.draftThreadsByThreadKey[threadKey]; |
| 1559 | base.activeProvider === stickyActiveProvider | 1974 | if ( |
| 1560 | ) { | 1975 | !draftThread || |
| 1561 | return state; | 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 = { | 1989 | set((state) => { |
| 1564 | ...base, | 1990 | const existing = state.draftThreadsByThreadKey[threadKey]; |
| 1565 | modelSelectionByProvider: nextMap, | 1991 | if (!existing) { |
| 1566 | activeProvider: stickyActiveProvider, | 1992 | return state; |
| 1567 | }; | 1993 | } |
| 1568 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 1994 | const nextPromotedTo = |
| 1569 | if (shouldRemoveDraft(nextDraft)) { | 1995 | promotedTo ?? scopeThreadRef(existing.environmentId, existing.threadId); |
| 1570 | delete nextDraftsByThreadId[threadId]; | 1996 | if (scopedThreadRefsEqual(existing.promotedTo, nextPromotedTo)) { |
| 1571 | } else { | 1997 | return state; |
| 1572 | nextDraftsByThreadId[threadId] = nextDraft; | 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 }; | 2015 | set((state) => { |
| 1575 | }); | 2016 | const existing = state.draftThreadsByThreadKey[threadKey]; |
| 1576 | }, | 2017 | if (!isDraftThreadPromoting(existing)) { |
| 1577 | setPrompt: (threadId, prompt) => { | 2018 | return state; |
| 1578 | if (threadId.length === 0) { | 2019 | } |
| 1579 | return; | 2020 | return removeDraftThreadReferences(state, threadKey); |
| 1580 | } | 2021 | }); |
| 1581 | set((state) => { | 2022 | }, |
| 1582 | const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | 2023 | clearDraftThread: (threadRef) => { |
| 1583 | const nextDraft: ComposerThreadDraftState = { | 2024 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 1584 | ...existing, | 2025 | if (threadKey.length === 0) { |
| 1585 | prompt, | 2026 | return; |
| 1586 | }; | ||
| 1587 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | ||
| 1588 | if (shouldRemoveDraft(nextDraft)) { | ||
| 1589 | delete nextDraftsByThreadId[threadId]; | ||
| 1590 | } else { | ||
| 1591 | nextDraftsByThreadId[threadId] = nextDraft; | ||
| 1592 | } | 2027 | } |
| 1593 | return { draftsByThreadId: nextDraftsByThreadId }; | 2028 | const existing = get().draftsByThreadKey[threadKey]; |
| 1594 | }); | 2029 | if (existing) { |
| 1595 | }, | 2030 | for (const image of existing.images) { |
| 1596 | setTerminalContexts: (threadId, contexts) => { | 2031 | revokeObjectPreviewUrl(image.previewUrl); |
| 1597 | if (threadId.length === 0) { | 2032 | } |
| 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; | ||
| 1616 | } | 2033 | } |
| 1617 | return { draftsByThreadId: nextDraftsByThreadId }; | 2034 | set((state) => { |
| 1618 | }); | 2035 | const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; |
| 1619 | }, | 2036 | const hasLogicalProjectMapping = Object.values( |
| 1620 | setModelSelection: (threadId, modelSelection) => { | 2037 | state.logicalProjectDraftThreadKeyByLogicalProjectKey, |
| 1621 | if (threadId.length === 0) { | 2038 | ).includes(threadKey); |
| 1622 | return; | 2039 | const hasComposerDraft = state.draftsByThreadKey[threadKey] !== undefined; |
| 1623 | } | 2040 | if (!hasDraftThread && !hasLogicalProjectMapping && !hasComposerDraft) { |
| 1624 | const normalized = normalizeModelSelection(modelSelection); | 2041 | return state; |
| 1625 | set((state) => { | 2042 | } |
| 1626 | const existing = state.draftsByThreadId[threadId]; | 2043 | return removeDraftThreadReferences(state, threadKey); |
| 1627 | if (!existing && normalized === null) { | 2044 | }); |
| 1628 | return state; | 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(); | 2072 | set((state) => { |
| 1631 | const nextMap = { ...base.modelSelectionByProvider }; | 2073 | const stickyMap = state.stickyModelSelectionByProvider; |
| 1632 | if (normalized) { | 2074 | const stickyActiveProvider = state.stickyActiveProvider; |
| 1633 | const current = nextMap[normalized.provider]; | 2075 | if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { |
| 1634 | if (normalized.options !== undefined) { | 2076 | return state; |
| 1635 | // Explicit options provided → use them | 2077 | } |
| 1636 | nextMap[normalized.provider] = normalized; | 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 | } else { | 2104 | } else { |
| 1638 | // No options in selection → preserve existing options, update provider+model | 2105 | nextDraftsByThreadKey[threadKey] = nextDraft; |
| 1639 | nextMap[normalized.provider] = { | ||
| 1640 | provider: normalized.provider, | ||
| 1641 | model: normalized.model, | ||
| 1642 | ...(current?.options ? { options: current.options } : {}), | ||
| 1643 | }; | ||
| 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; | 2115 | set((state) => { |
| 1647 | if ( | 2116 | const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); |
| 1648 | Equal.equals(base.modelSelectionByProvider, nextMap) && | 2117 | const nextDraft: ComposerThreadDraftState = { |
| 1649 | base.activeProvider === nextActiveProvider | 2118 | ...existing, |
| 1650 | ) { | 2119 | prompt, |
| 1651 | return state; | 2120 | }; |
| 1652 | } | 2121 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 1653 | const nextDraft: ComposerThreadDraftState = { | 2122 | if (shouldRemoveDraft(nextDraft)) { |
| 1654 | ...base, | 2123 | delete nextDraftsByThreadKey[threadKey]; |
| 1655 | modelSelectionByProvider: nextMap, | 2124 | } else { |
| 1656 | activeProvider: nextActiveProvider, | 2125 | nextDraftsByThreadKey[threadKey] = nextDraft; |
| 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; | ||
| 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)) { | 2136 | const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts); |
| 1697 | return state; | 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 = { | 2161 | const normalized = normalizeModelSelection(modelSelection); |
| 1700 | ...base, | 2162 | set((state) => { |
| 1701 | modelSelectionByProvider: nextMap, | 2163 | const existing = state.draftsByThreadKey[threadKey]; |
| 1702 | }; | 2164 | if (!existing && normalized === null) { |
| 1703 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 2165 | return state; |
| 1704 | if (shouldRemoveDraft(nextDraft)) { | 2166 | } |
| 1705 | delete nextDraftsByThreadId[threadId]; | 2167 | const base = existing ?? createEmptyThreadDraft(); |
| 1706 | } else { | 2168 | const nextMap = { ...base.modelSelectionByProvider }; |
| 1707 | nextDraftsByThreadId[threadId] = nextDraft; | 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 }; | 2209 | const normalizedOpts = normalizeProviderModelOptions(modelOptions); |
| 1710 | }); | 2210 | set((state) => { |
| 1711 | }, | 2211 | const existing = state.draftsByThreadKey[threadKey]; |
| 1712 | setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => { | 2212 | if (!existing && normalizedOpts === null) { |
| 1713 | if (threadId.length === 0) { | 2213 | return state; |
| 1714 | return; | 2214 | } |
| 1715 | } | 2215 | const base = existing ?? createEmptyThreadDraft(); |
| 1716 | const normalizedProvider = normalizeProviderKind(provider); | 2216 | const nextMap = { ...base.modelSelectionByProvider }; |
| 1717 | if (normalizedProvider === null) { | 2217 | for (const provider of ["codex", "claudeAgent"] as const) { |
| 1718 | return; | 2218 | // Only touch providers explicitly present in the input |
| 1719 | } | 2219 | if (!normalizedOpts || !(provider in normalizedOpts)) continue; |
| 1720 | // Normalize just this provider's options | 2220 | const opts = normalizedOpts[provider]; |
| 1721 | const normalizedOpts = normalizeProviderModelOptions( | 2221 | const current = nextMap[provider]; |
| 1722 | { [normalizedProvider]: nextProviderOptions }, | 2222 | if (opts) { |
| 1723 | normalizedProvider, | 2223 | nextMap[provider] = { |
| 1724 | ); | 2224 | provider, |
| 1725 | const providerOpts = normalizedOpts?.[normalizedProvider]; | 2225 | model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], |
| 1726 | 2226 | options: opts, | |
| 1727 | set((state) => { | 2227 | }; |
| 1728 | const existing = state.draftsByThreadId[threadId]; | 2228 | } else if (current?.options) { |
| 1729 | const base = existing ?? createEmptyThreadDraft(); | 2229 | // Remove options but keep the selection |
| 1730 | 2230 | const { options: _, ...rest } = current; | |
| 1731 | // Update the map entry for this provider | 2231 | nextMap[provider] = rest as ModelSelection; |
| 1732 | const nextMap = { ...base.modelSelectionByProvider }; | 2232 | } |
| 1733 | const currentForProvider = nextMap[normalizedProvider]; | 2233 | } |
| 1734 | if (providerOpts) { | 2234 | if (Equal.equals(base.modelSelectionByProvider, nextMap)) { |
| 1735 | nextMap[normalizedProvider] = { | 2235 | return state; |
| 1736 | provider: normalizedProvider, | 2236 | } |
| 1737 | model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | 2237 | const nextDraft: ComposerThreadDraftState = { |
| 1738 | options: providerOpts, | 2238 | ...base, |
| 2239 | modelSelectionByProvider: nextMap, | ||
| 1739 | }; | 2240 | }; |
| 1740 | } else if (currentForProvider?.options) { | 2241 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 1741 | const { options: _, ...rest } = currentForProvider; | 2242 | if (shouldRemoveDraft(nextDraft)) { |
| 1742 | nextMap[normalizedProvider] = rest as ModelSelection; | 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 | 2266 | set((state) => { |
| 1746 | let nextStickyMap = state.stickyModelSelectionByProvider; | 2267 | const existing = state.draftsByThreadKey[threadKey]; |
| 1747 | let nextStickyActiveProvider = state.stickyActiveProvider; | 2268 | const base = existing ?? createEmptyThreadDraft(); |
| 1748 | if (options?.persistSticky === true) { | 2269 | |
| 1749 | nextStickyMap = { ...state.stickyModelSelectionByProvider }; | 2270 | // Update the map entry for this provider |
| 1750 | const stickyBase = | 2271 | const nextMap = { ...base.modelSelectionByProvider }; |
| 1751 | nextStickyMap[normalizedProvider] ?? | 2272 | const currentForProvider = nextMap[normalizedProvider]; |
| 1752 | base.modelSelectionByProvider[normalizedProvider] ?? | ||
| 1753 | ({ | ||
| 1754 | provider: normalizedProvider, | ||
| 1755 | model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | ||
| 1756 | } as ModelSelection); | ||
| 1757 | if (providerOpts) { | 2273 | if (providerOpts) { |
| 1758 | nextStickyMap[normalizedProvider] = { | 2274 | nextMap[normalizedProvider] = { |
| 1759 | ...stickyBase, | ||
| 1760 | provider: normalizedProvider, | 2275 | provider: normalizedProvider, |
| 2276 | model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | ||
| 1761 | options: providerOpts, | 2277 | options: providerOpts, |
| 1762 | }; | 2278 | }; |
| 1763 | } else if (stickyBase.options) { | 2279 | } else if (currentForProvider?.options) { |
| 1764 | const { options: _, ...rest } = stickyBase; | 2280 | const { options: _, ...rest } = currentForProvider; |
| 1765 | nextStickyMap[normalizedProvider] = rest as ModelSelection; | 2281 | nextMap[normalizedProvider] = rest as ModelSelection; |
| 1766 | } | 2282 | } |
| 1767 | nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; | ||
| 1768 | } | ||
| 1769 | 2283 | ||
| 1770 | if ( | 2284 | // Handle sticky persistence |
| 1771 | Equal.equals(base.modelSelectionByProvider, nextMap) && | 2285 | let nextStickyMap = state.stickyModelSelectionByProvider; |
| 1772 | Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && | 2286 | let nextStickyActiveProvider = state.stickyActiveProvider; |
| 1773 | state.stickyActiveProvider === nextStickyActiveProvider | 2287 | if (options?.persistSticky === true) { |
| 1774 | ) { | 2288 | nextStickyMap = { ...state.stickyModelSelectionByProvider }; |
| 1775 | return state; | 2289 | const stickyBase = |
| 1776 | } | 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 = { | 2309 | if ( |
| 1779 | ...base, | 2310 | Equal.equals(base.modelSelectionByProvider, nextMap) && |
| 1780 | modelSelectionByProvider: nextMap, | 2311 | Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && |
| 1781 | }; | 2312 | state.stickyActiveProvider === nextStickyActiveProvider |
| 1782 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 2313 | ) { |
| 1783 | if (shouldRemoveDraft(nextDraft)) { | 2314 | return state; |
| 1784 | delete nextDraftsByThreadId[threadId]; | 2315 | } |
| 1785 | } else { | ||
| 1786 | nextDraftsByThreadId[threadId] = nextDraft; | ||
| 1787 | } | ||
| 1788 | 2316 | ||
| 1789 | return { | 2317 | const nextDraft: ComposerThreadDraftState = { |
| 1790 | draftsByThreadId: nextDraftsByThreadId, | 2318 | ...base, |
| 1791 | ...(options?.persistSticky === true | 2319 | modelSelectionByProvider: nextMap, |
| 1792 | ? { | 2320 | }; |
| 1793 | stickyModelSelectionByProvider: nextStickyMap, | 2321 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 1794 | stickyActiveProvider: nextStickyActiveProvider, | 2322 | if (shouldRemoveDraft(nextDraft)) { |
| 1795 | } | 2323 | delete nextDraftsByThreadKey[threadKey]; |
| 1796 | : {}), | 2324 | } else { |
| 1797 | }; | 2325 | nextDraftsByThreadKey[threadKey] = nextDraft; |
| 1798 | }); | 2326 | } |
| 1799 | }, | 2327 | |
| 1800 | setRuntimeMode: (threadId, runtimeMode) => { | 2328 | return { |
| 1801 | if (threadId.length === 0) { | 2329 | draftsByThreadKey: nextDraftsByThreadKey, |
| 1802 | return; | 2330 | ...(options?.persistSticky === true |
| 1803 | } | 2331 | ? { |
| 1804 | const nextRuntimeMode = | 2332 | stickyModelSelectionByProvider: nextStickyMap, |
| 1805 | runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; | 2333 | stickyActiveProvider: nextStickyActiveProvider, |
| 1806 | set((state) => { | 2334 | } |
| 1807 | const existing = state.draftsByThreadId[threadId]; | 2335 | : {}), |
| 1808 | if (!existing && nextRuntimeMode === null) { | 2336 | }; |
| 1809 | return state; | 2337 | }); |
| 1810 | } | 2338 | }, |
| 1811 | const base = existing ?? createEmptyThreadDraft(); | 2339 | setRuntimeMode: (threadRef, runtimeMode) => { |
| 1812 | if (base.runtimeMode === nextRuntimeMode) { | 2340 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 1813 | return state; | 2341 | if (threadKey.length === 0) { |
| 1814 | } | 2342 | return; |
| 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; | ||
| 1838 | } | 2343 | } |
| 1839 | const base = existing ?? createEmptyThreadDraft(); | 2344 | const nextRuntimeMode = |
| 1840 | if (base.interactionMode === nextInteractionMode) { | 2345 | runtimeMode === "approval-required" || runtimeMode === "full-access" |
| 1841 | return state; | 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 = { | 2375 | const nextInteractionMode = |
| 1844 | ...base, | 2376 | interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; |
| 1845 | interactionMode: nextInteractionMode, | 2377 | set((state) => { |
| 1846 | }; | 2378 | const existing = state.draftsByThreadKey[threadKey]; |
| 1847 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 2379 | if (!existing && nextInteractionMode === null) { |
| 1848 | if (shouldRemoveDraft(nextDraft)) { | 2380 | return state; |
| 1849 | delete nextDraftsByThreadId[threadId]; | 2381 | } |
| 1850 | } else { | 2382 | const base = existing ?? createEmptyThreadDraft(); |
| 1851 | nextDraftsByThreadId[threadId] = nextDraft; | 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 }; | 2405 | get().addImages( |
| 1854 | }); | 2406 | typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef, |
| 1855 | }, | 2407 | [image], |
| 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)), | ||
| 1871 | ); | 2408 | ); |
| 1872 | const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); | 2409 | }, |
| 1873 | const dedupedIncoming: ComposerImageAttachment[] = []; | 2410 | addImages: (threadRef, images) => { |
| 1874 | for (const image of images) { | 2411 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 1875 | const dedupKey = composerImageDedupKey(image); | 2412 | if (threadKey.length === 0 || images.length === 0) { |
| 1876 | if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { | 2413 | return; |
| 1877 | // Avoid revoking a blob URL that's still referenced by an accepted image. | 2414 | } |
| 1878 | if (!acceptedPreviewUrls.has(image.previewUrl)) { | 2415 | set((state) => { |
| 1879 | revokeObjectPreviewUrl(image.previewUrl); | 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); | 2437 | if (dedupedIncoming.length === 0) { |
| 1884 | existingIds.add(image.id); | 2438 | return state; |
| 1885 | existingDedupKeys.add(dedupKey); | 2439 | } |
| 1886 | acceptedPreviewUrls.add(image.previewUrl); | 2440 | return { |
| 1887 | } | 2441 | draftsByThreadKey: { |
| 1888 | if (dedupedIncoming.length === 0) { | 2442 | ...state.draftsByThreadKey, |
| 1889 | return state; | 2443 | [threadKey]: { |
| 1890 | } | 2444 | ...existing, |
| 1891 | return { | 2445 | images: [...existing.images, ...dedupedIncoming], |
| 1892 | draftsByThreadId: { | 2446 | }, |
| 1893 | ...state.draftsByThreadId, | ||
| 1894 | [threadId]: { | ||
| 1895 | ...existing, | ||
| 1896 | images: [...existing.images, ...dedupedIncoming], | ||
| 1897 | }, | 2447 | }, |
| 1898 | }, | 2448 | }; |
| 1899 | }; | 2449 | }); |
| 1900 | }); | 2450 | }, |
| 1901 | }, | 2451 | removeImage: (threadRef, imageId) => { |
| 1902 | removeImage: (threadId, imageId) => { | 2452 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 1903 | if (threadId.length === 0) { | 2453 | if (threadKey.length === 0) { |
| 1904 | return; | 2454 | 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; | ||
| 1932 | } | 2455 | } |
| 1933 | return { draftsByThreadId: nextDraftsByThreadId }; | 2456 | const existing = get().draftsByThreadKey[threadKey]; |
| 1934 | }); | 2457 | if (!existing) { |
| 1935 | }, | 2458 | return; |
| 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; | ||
| 1946 | } | 2459 | } |
| 1947 | const dedupKey = terminalContextDedupKey(normalizedContext); | 2460 | const removedImage = existing.images.find((image) => image.id === imageId); |
| 1948 | if ( | 2461 | if (removedImage) { |
| 1949 | existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) || | 2462 | revokeObjectPreviewUrl(removedImage.previewUrl); |
| 1950 | existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey) | ||
| 1951 | ) { | ||
| 1952 | return state; | ||
| 1953 | } | 2463 | } |
| 1954 | inserted = true; | 2464 | set((state) => { |
| 1955 | const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index)); | 2465 | const current = state.draftsByThreadKey[threadKey]; |
| 1956 | const nextDraft: ComposerThreadDraftState = { | 2466 | if (!current) { |
| 1957 | ...existing, | 2467 | return state; |
| 1958 | prompt, | 2468 | } |
| 1959 | terminalContexts: [ | 2469 | const nextDraft: ComposerThreadDraftState = { |
| 1960 | ...existing.terminalContexts.slice(0, boundedIndex), | 2470 | ...current, |
| 1961 | normalizedContext, | 2471 | images: current.images.filter((image) => image.id !== imageId), |
| 1962 | ...existing.terminalContexts.slice(boundedIndex), | 2472 | nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), |
| 1963 | ], | 2473 | persistedAttachments: current.persistedAttachments.filter( |
| 1964 | }; | 2474 | (attachment) => attachment.id !== imageId, |
| 1965 | return { | 2475 | ), |
| 1966 | draftsByThreadId: { | 2476 | }; |
| 1967 | ...state.draftsByThreadId, | 2477 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 1968 | [threadId]: nextDraft, | 2478 | if (shouldRemoveDraft(nextDraft)) { |
| 1969 | }, | 2479 | delete nextDraftsByThreadKey[threadKey]; |
| 1970 | }; | 2480 | } else { |
| 1971 | }); | 2481 | nextDraftsByThreadKey[threadKey] = nextDraft; |
| 1972 | return inserted; | 2482 | } |
| 1973 | }, | 2483 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 1974 | addTerminalContext: (threadId, context) => { | 2484 | }); |
| 1975 | if (threadId.length === 0) { | 2485 | }, |
| 1976 | return; | 2486 | insertTerminalContext: (threadRef, prompt, context, index) => { |
| 1977 | } | 2487 | const threadKey = resolveComposerDraftKey(get(), threadRef); |
| 1978 | get().addTerminalContexts(threadId, [context]); | 2488 | const threadId = resolveComposerThreadId(get(), threadRef); |
| 1979 | }, | 2489 | if (!threadKey || !threadId) { |
| 1980 | addTerminalContexts: (threadId, contexts) => { | 2490 | return false; |
| 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; | ||
| 1992 | } | 2491 | } |
| 1993 | return { | 2492 | let inserted = false; |
| 1994 | draftsByThreadId: { | 2493 | set((state) => { |
| 1995 | ...state.draftsByThreadId, | 2494 | const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); |
| 1996 | [threadId]: { | 2495 | const normalizedContext = normalizeTerminalContextForThread(threadId, context); |
| 1997 | ...existing, | 2496 | if (!normalizedContext) { |
| 1998 | prompt: ensureInlineTerminalContextPlaceholders( | 2497 | return state; |
| 1999 | existing.prompt, | 2498 | } |
| 2000 | existing.terminalContexts.length + acceptedContexts.length, | 2499 | const dedupKey = terminalContextDedupKey(normalizedContext); |
| 2001 | ), | 2500 | if ( |
| 2002 | terminalContexts: [...existing.terminalContexts, ...acceptedContexts], | 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 | }, | 2522 | }; |
| 2005 | }; | 2523 | }); |
| 2006 | }); | 2524 | return inserted; |
| 2007 | }, | 2525 | }, |
| 2008 | removeTerminalContext: (threadId, contextId) => { | 2526 | addTerminalContext: (threadRef, context) => { |
| 2009 | if (threadId.length === 0 || contextId.length === 0) { | 2527 | const threadKey = resolveComposerDraftKey(get(), threadRef); |
| 2010 | return; | 2528 | const threadId = resolveComposerThreadId(get(), threadRef); |
| 2011 | } | 2529 | if (!threadKey || !threadId) { |
| 2012 | set((state) => { | 2530 | return; |
| 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; | ||
| 2050 | } | 2531 | } |
| 2051 | return { draftsByThreadId: nextDraftsByThreadId }; | 2532 | get().addTerminalContexts( |
| 2052 | }); | 2533 | typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef, |
| 2053 | }, | 2534 | [context], |
| 2054 | clearPersistedAttachments: (threadId) => { | 2535 | ); |
| 2055 | if (threadId.length === 0) { | 2536 | }, |
| 2056 | return; | 2537 | addTerminalContexts: (threadRef, contexts) => { |
| 2057 | } | 2538 | const threadKey = resolveComposerDraftKey(get(), threadRef); |
| 2058 | set((state) => { | 2539 | const threadId = resolveComposerThreadId(get(), threadRef); |
| 2059 | const current = state.draftsByThreadId[threadId]; | 2540 | if (!threadKey || !threadId || contexts.length === 0) { |
| 2060 | if (!current) { | 2541 | return; |
| 2061 | return state; | ||
| 2062 | } | 2542 | } |
| 2063 | const nextDraft: ComposerThreadDraftState = { | 2543 | set((state) => { |
| 2064 | ...current, | 2544 | const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); |
| 2065 | persistedAttachments: [], | 2545 | const acceptedContexts = normalizeTerminalContextsForThread(threadId, [ |
| 2066 | nonPersistedImageIds: [], | 2546 | ...existing.terminalContexts, |
| 2067 | }; | 2547 | ...contexts, |
| 2068 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 2548 | ]).slice(existing.terminalContexts.length); |
| 2069 | if (shouldRemoveDraft(nextDraft)) { | 2549 | if (acceptedContexts.length === 0) { |
| 2070 | delete nextDraftsByThreadId[threadId]; | 2550 | return state; |
| 2071 | } else { | 2551 | } |
| 2072 | nextDraftsByThreadId[threadId] = nextDraft; | 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 }; | 2572 | set((state) => { |
| 2075 | }); | 2573 | const current = state.draftsByThreadKey[threadKey]; |
| 2076 | }, | 2574 | if (!current) { |
| 2077 | syncPersistedAttachments: (threadId, attachments) => { | 2575 | return state; |
| 2078 | if (threadId.length === 0) { | 2576 | } |
| 2079 | return; | 2577 | const nextDraft: ComposerThreadDraftState = { |
| 2080 | } | 2578 | ...current, |
| 2081 | const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); | 2579 | terminalContexts: current.terminalContexts.filter( |
| 2082 | set((state) => { | 2580 | (context) => context.id !== contextId, |
| 2083 | const current = state.draftsByThreadId[threadId]; | 2581 | ), |
| 2084 | if (!current) { | 2582 | }; |
| 2085 | return state; | 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 = { | 2597 | set((state) => { |
| 2088 | ...current, | 2598 | const current = state.draftsByThreadKey[threadKey]; |
| 2089 | // Stage attempted attachments so persist middleware can try writing them. | 2599 | if (!current || current.terminalContexts.length === 0) { |
| 2090 | persistedAttachments: attachments, | 2600 | return state; |
| 2091 | nonPersistedImageIds: current.nonPersistedImageIds.filter( | 2601 | } |
| 2092 | (id) => !attachmentIdSet.has(id), | 2602 | const nextDraft: ComposerThreadDraftState = { |
| 2093 | ), | 2603 | ...current, |
| 2094 | }; | 2604 | terminalContexts: [], |
| 2095 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 2605 | }; |
| 2096 | if (shouldRemoveDraft(nextDraft)) { | 2606 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 2097 | delete nextDraftsByThreadId[threadId]; | 2607 | if (shouldRemoveDraft(nextDraft)) { |
| 2098 | } else { | 2608 | delete nextDraftsByThreadKey[threadKey]; |
| 2099 | nextDraftsByThreadId[threadId] = nextDraft; | 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 }; | 2620 | set((state) => { |
| 2102 | }); | 2621 | const current = state.draftsByThreadKey[threadKey]; |
| 2103 | Promise.resolve().then(() => { | 2622 | if (!current) { |
| 2104 | verifyPersistedAttachments(threadId, attachments, set); | 2623 | return state; |
| 2105 | }); | 2624 | } |
| 2106 | }, | 2625 | const nextDraft: ComposerThreadDraftState = { |
| 2107 | clearComposerContent: (threadId) => { | 2626 | ...current, |
| 2108 | if (threadId.length === 0) { | 2627 | persistedAttachments: [], |
| 2109 | return; | 2628 | nonPersistedImageIds: [], |
| 2110 | } | 2629 | }; |
| 2111 | set((state) => { | 2630 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 2112 | const current = state.draftsByThreadId[threadId]; | 2631 | if (shouldRemoveDraft(nextDraft)) { |
| 2113 | if (!current) { | 2632 | delete nextDraftsByThreadKey[threadKey]; |
| 2114 | return state; | 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 = { | 2644 | const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); |
| 2117 | ...current, | 2645 | set((state) => { |
| 2118 | prompt: "", | 2646 | const current = state.draftsByThreadKey[threadKey]; |
| 2119 | images: [], | 2647 | if (!current) { |
| 2120 | nonPersistedImageIds: [], | 2648 | return state; |
| 2121 | persistedAttachments: [], | 2649 | } |
| 2122 | terminalContexts: [], | 2650 | const nextDraft: ComposerThreadDraftState = { |
| 2123 | }; | 2651 | ...current, |
| 2124 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 2652 | // Stage attempted attachments so persist middleware can try writing them. |
| 2125 | if (shouldRemoveDraft(nextDraft)) { | 2653 | persistedAttachments: attachments, |
| 2126 | delete nextDraftsByThreadId[threadId]; | 2654 | nonPersistedImageIds: current.nonPersistedImageIds.filter( |
| 2127 | } else { | 2655 | (id) => !attachmentIdSet.has(id), |
| 2128 | nextDraftsByThreadId[threadId] = nextDraft; | 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 }; | 2675 | set((state) => { |
| 2131 | }); | 2676 | const current = state.draftsByThreadKey[threadKey]; |
| 2132 | }, | 2677 | if (!current) { |
| 2133 | }), | 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 | name: COMPOSER_DRAFT_STORAGE_KEY, | 2700 | name: COMPOSER_DRAFT_STORAGE_KEY, |
| 2136 | version: COMPOSER_DRAFT_STORAGE_VERSION, | 2701 | version: COMPOSER_DRAFT_STORAGE_VERSION, |
| ... | @@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 2140 | merge: (persistedState, currentState) => { | 2705 | merge: (persistedState, currentState) => { |
| 2141 | const normalizedPersisted = | 2706 | const normalizedPersisted = |
| 2142 | normalizeCurrentPersistedComposerDraftStoreState(persistedState); | 2707 | normalizeCurrentPersistedComposerDraftStoreState(persistedState); |
| 2143 | const draftsByThreadId = Object.fromEntries( | 2708 | const draftsByThreadKey = Object.fromEntries( |
| 2144 | Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ | 2709 | Object.entries(normalizedPersisted.draftsByThreadKey).map(([threadKey, draft]) => [ |
| 2145 | threadId, | 2710 | threadKey, |
| 2146 | toHydratedThreadDraft(draft), | 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 | return { | 2719 | return { |
| 2150 | ...currentState, | 2720 | ...currentState, |
| 2151 | draftsByThreadId, | 2721 | draftsByThreadKey, |
| 2152 | draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, | 2722 | draftThreadsByThreadKey, |
| 2153 | projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, | 2723 | logicalProjectDraftThreadKeyByLogicalProjectKey: |
| 2724 | normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, | ||
| 2154 | stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, | 2725 | stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, |
| 2155 | stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, | 2726 | stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, |
| 2156 | }; | 2727 | }; |
| ... | @@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 2159 | ), | 2730 | ), |
| 2160 | ); | 2731 | ); |
| 2161 | 2732 | ||
| 2162 | export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { | 2733 | export const useComposerDraftStore = composerDraftStore; |
| 2163 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); | 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 | export function useEffectiveComposerModelState(input: { | 2741 | export function useEffectiveComposerModelState(input: { |
| 2167 | threadId: ThreadId; | 2742 | threadRef?: ComposerThreadTarget; |
| 2743 | draftId?: DraftId; | ||
| 2168 | providers: ReadonlyArray<ServerProvider>; | 2744 | providers: ReadonlyArray<ServerProvider>; |
| 2169 | selectedProvider: ProviderKind; | 2745 | selectedProvider: ProviderKind; |
| 2170 | threadModelSelection: ModelSelection | null | undefined; | 2746 | threadModelSelection: ModelSelection | null | undefined; |
| 2171 | projectModelSelection: ModelSelection | null | undefined; | 2747 | projectModelSelection: ModelSelection | null | undefined; |
| 2172 | settings: UnifiedSettings; | 2748 | settings: UnifiedSettings; |
| 2173 | }): EffectiveComposerModelState { | 2749 | }): EffectiveComposerModelState { |
| 2174 | const draft = useComposerThreadDraft(input.threadId); | 2750 | const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe("")); |
| 2175 | 2751 | ||
| 2176 | return useMemo( | 2752 | return useMemo( |
| 2177 | () => | 2753 | () => |
| ... | @@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: { | ... | @@ -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 | * Use the single-thread helper for live `thread.created` events and the | 2776 | * Use the single-thread helper for live `thread.created` events and the |
| 2201 | * iterable helper for bootstrap/recovery paths that discover multiple server | 2777 | * iterable helper for bootstrap/recovery paths that discover multiple server |
| 2202 | * threads at once. | 2778 | * threads at once. |
| 2203 | */ | 2779 | */ |
| 2204 | export function clearPromotedDraftThread(threadId: ThreadId): void { | 2780 | export function markPromotedDraftThread(threadId: ThreadId): void { |
| 2205 | if (!useComposerDraftStore.getState().getDraftThread(threadId)) { | 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 | return; | 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 | for (const threadId of serverThreadIds) { | 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,4 +1,4 @@ |
| 1 | import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts"; | 1 | import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; |
| 2 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; | 2 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; |
| 3 | import { useMemo } from "react"; | 3 | import { useMemo } from "react"; |
| 4 | 4 | ||
| ... | @@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor( | ... | @@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor( |
| 26 | return editor ?? null; | 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 | const { availableEditors } = await api.server.getConfig(); | 30 | const { availableEditors } = await api.server.getConfig(); |
| 31 | const editor = resolveAndPersistPreferredEditor(availableEditors); | 31 | const editor = resolveAndPersistPreferredEditor(availableEditors); |
| 32 | if (!editor) throw new Error("No available editors found."); | 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,5 +1,6 @@ |
| 1 | import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; |
| 2 | import { useNavigate, useParams } from "@tanstack/react-router"; | 2 | import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; |
| 3 | import { useParams, useRouter } from "@tanstack/react-router"; | ||
| 3 | import { useCallback, useMemo } from "react"; | 4 | import { useCallback, useMemo } from "react"; |
| 4 | import { useShallow } from "zustand/react/shallow"; | 5 | import { useShallow } from "zustand/react/shallow"; |
| 5 | import { | 6 | import { |
| ... | @@ -7,37 +8,49 @@ import { | ... | @@ -7,37 +8,49 @@ import { |
| 7 | type DraftThreadState, | 8 | type DraftThreadState, |
| 8 | useComposerDraftStore, | 9 | useComposerDraftStore, |
| 9 | } from "../composerDraftStore"; | 10 | } from "../composerDraftStore"; |
| 10 | import { newThreadId } from "../lib/utils"; | 11 | import { newDraftId, newThreadId } from "../lib/utils"; |
| 11 | import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; | 12 | import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; |
| 12 | import { useStore } from "../store"; | 13 | import { deriveLogicalProjectKey } from "../logicalProject"; |
| 13 | import { createThreadSelector } from "../storeSelectors"; | 14 | import { selectProjectsAcrossEnvironments, useStore } from "../store"; |
| 15 | import { createThreadSelectorByRef } from "../storeSelectors"; | ||
| 16 | import { resolveThreadRouteTarget } from "../threadRoutes"; | ||
| 14 | import { useUiStateStore } from "../uiStateStore"; | 17 | import { useUiStateStore } from "../uiStateStore"; |
| 15 | 18 | ||
| 16 | export function useHandleNewThread() { | 19 | export function useHandleNewThread() { |
| 17 | const projectIds = useStore(useShallow((store) => store.projectIds)); | 20 | const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); |
| 18 | const projectOrder = useUiStateStore((store) => store.projectOrder); | 21 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 19 | const navigate = useNavigate(); | 22 | const router = useRouter(); |
| 20 | const routeThreadId = useParams({ | 23 | const routeTarget = useParams({ |
| 21 | strict: false, | 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 | const activeThread = useStore( | 28 | const activeThread = useStore( |
| 25 | useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]), | 29 | useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), |
| 26 | ); | 30 | ); |
| 27 | const activeDraftThread = useComposerDraftStore((store) => | 31 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); |
| 28 | routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, | 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 | const orderedProjects = useMemo(() => { | 39 | const orderedProjects = useMemo(() => { |
| 31 | return orderItemsByPreferredIds({ | 40 | return orderItemsByPreferredIds({ |
| 32 | items: projectIds, | 41 | items: projects, |
| 33 | preferredIds: projectOrder, | 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 | const handleNewThread = useCallback( | 51 | const handleNewThread = useCallback( |
| 39 | ( | 52 | ( |
| 40 | projectId: ProjectId, | 53 | projectRef: ScopedProjectRef, |
| 41 | options?: { | 54 | options?: { |
| 42 | branch?: string | null; | 55 | branch?: string | null; |
| 43 | worktreePath?: string | null; | 56 | worktreePath?: string | null; |
| ... | @@ -45,84 +58,110 @@ export function useHandleNewThread() { | ... | @@ -45,84 +58,110 @@ export function useHandleNewThread() { |
| 45 | }, | 58 | }, |
| 46 | ): Promise<void> => { | 59 | ): Promise<void> => { |
| 47 | const { | 60 | const { |
| 48 | clearProjectDraftThreadId, | 61 | getDraftSessionByLogicalProjectKey, |
| 62 | getDraftSession, | ||
| 49 | getDraftThread, | 63 | getDraftThread, |
| 50 | getDraftThreadByProjectId, | ||
| 51 | applyStickyState, | 64 | applyStickyState, |
| 52 | setDraftThreadContext, | 65 | setDraftThreadContext, |
| 53 | setProjectDraftThreadId, | 66 | setLogicalProjectDraftThreadId, |
| 54 | } = useComposerDraftStore.getState(); | 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 | const hasBranchOption = options?.branch !== undefined; | 77 | const hasBranchOption = options?.branch !== undefined; |
| 56 | const hasWorktreePathOption = options?.worktreePath !== undefined; | 78 | const hasWorktreePathOption = options?.worktreePath !== undefined; |
| 57 | const hasEnvModeOption = options?.envMode !== undefined; | 79 | const hasEnvModeOption = options?.envMode !== undefined; |
| 58 | const storedDraftThread = getDraftThreadByProjectId(projectId); | 80 | const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); |
| 59 | const latestActiveDraftThread: DraftThreadState | null = routeThreadId | 81 | const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget |
| 60 | ? getDraftThread(routeThreadId) | 82 | ? currentRouteTarget.kind === "server" |
| 83 | ? getDraftThread(currentRouteTarget.threadRef) | ||
| 84 | : getDraftSession(currentRouteTarget.draftId) | ||
| 61 | : null; | 85 | : null; |
| 62 | if (storedDraftThread) { | 86 | if (storedDraftThread) { |
| 63 | return (async () => { | 87 | return (async () => { |
| 64 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { | 88 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { |
| 65 | setDraftThreadContext(storedDraftThread.threadId, { | 89 | setDraftThreadContext(storedDraftThread.draftId, { |
| 66 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | 90 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), |
| 67 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | 91 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), |
| 68 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | 92 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), |
| 69 | }); | 93 | }); |
| 70 | } | 94 | } |
| 71 | setProjectDraftThreadId(projectId, storedDraftThread.threadId); | 95 | setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, { |
| 72 | if (routeThreadId === storedDraftThread.threadId) { | 96 | threadId: storedDraftThread.threadId, |
| 97 | }); | ||
| 98 | if ( | ||
| 99 | currentRouteTarget?.kind === "draft" && | ||
| 100 | currentRouteTarget.draftId === storedDraftThread.draftId | ||
| 101 | ) { | ||
| 73 | return; | 102 | return; |
| 74 | } | 103 | } |
| 75 | await navigate({ | 104 | await router.navigate({ |
| 76 | to: "/$threadId", | 105 | to: "/draft/$draftId", |
| 77 | params: { threadId: storedDraftThread.threadId }, | 106 | params: { draftId: storedDraftThread.draftId }, |
| 78 | }); | 107 | }); |
| 79 | })(); | 108 | })(); |
| 80 | } | 109 | } |
| 81 | 110 | ||
| 82 | clearProjectDraftThreadId(projectId); | ||
| 83 | |||
| 84 | if ( | 111 | if ( |
| 85 | latestActiveDraftThread && | 112 | latestActiveDraftThread && |
| 86 | routeThreadId && | 113 | currentRouteTarget?.kind === "draft" && |
| 87 | latestActiveDraftThread.projectId === projectId | 114 | latestActiveDraftThread.logicalProjectKey === logicalProjectKey |
| 88 | ) { | 115 | ) { |
| 89 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { | 116 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { |
| 90 | setDraftThreadContext(routeThreadId, { | 117 | setDraftThreadContext(currentRouteTarget.draftId, { |
| 91 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | 118 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), |
| 92 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | 119 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), |
| 93 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | 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 | return Promise.resolve(); | 132 | return Promise.resolve(); |
| 98 | } | 133 | } |
| 99 | 134 | ||
| 135 | const draftId = newDraftId(); | ||
| 100 | const threadId = newThreadId(); | 136 | const threadId = newThreadId(); |
| 101 | const createdAt = new Date().toISOString(); | 137 | const createdAt = new Date().toISOString(); |
| 102 | return (async () => { | 138 | return (async () => { |
| 103 | setProjectDraftThreadId(projectId, threadId, { | 139 | setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { |
| 140 | threadId, | ||
| 104 | createdAt, | 141 | createdAt, |
| 105 | branch: options?.branch ?? null, | 142 | branch: options?.branch ?? null, |
| 106 | worktreePath: options?.worktreePath ?? null, | 143 | worktreePath: options?.worktreePath ?? null, |
| 107 | envMode: options?.envMode ?? "local", | 144 | envMode: options?.envMode ?? "local", |
| 108 | runtimeMode: DEFAULT_RUNTIME_MODE, | 145 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 109 | }); | 146 | }); |
| 110 | applyStickyState(threadId); | 147 | applyStickyState(draftId); |
| 111 | 148 | ||
| 112 | await navigate({ | 149 | await router.navigate({ |
| 113 | to: "/$threadId", | 150 | to: "/draft/$draftId", |
| 114 | params: { threadId }, | 151 | params: { draftId }, |
| 115 | }); | 152 | }); |
| 116 | })(); | 153 | })(); |
| 117 | }, | 154 | }, |
| 118 | [navigate, routeThreadId], | 155 | [getCurrentRouteTarget, router, projects], |
| 119 | ); | 156 | ); |
| 120 | 157 | ||
| 121 | return { | 158 | return { |
| 122 | activeDraftThread, | 159 | activeDraftThread, |
| 123 | activeThread, | 160 | activeThread, |
| 124 | defaultProjectId: orderedProjects[0] ?? null, | 161 | defaultProjectRef: orderedProjects[0] |
| 162 | ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id) | ||
| 163 | : null, | ||
| 125 | handleNewThread, | 164 | handleNewThread, |
| 126 | routeThreadId, | 165 | routeThreadRef, |
| 127 | }; | 166 | }; |
| 128 | } | 167 | } |
apps/web/src/hooks/useSettings.ts+4-6| ... | @@ -26,7 +26,7 @@ import { | ... | @@ -26,7 +26,7 @@ import { |
| 26 | TimestampFormat, | 26 | TimestampFormat, |
| 27 | UnifiedSettings, | 27 | UnifiedSettings, |
| 28 | } from "@t3tools/contracts/settings"; | 28 | } from "@t3tools/contracts/settings"; |
| 29 | import { ensureNativeApi } from "~/nativeApi"; | 29 | import { ensureLocalApi } from "~/localApi"; |
| 30 | import { useLocalStorage } from "./useLocalStorage"; | 30 | import { useLocalStorage } from "./useLocalStorage"; |
| 31 | import { normalizeCustomModelSlugs } from "~/modelSelection"; | 31 | import { normalizeCustomModelSlugs } from "~/modelSelection"; |
| 32 | import { Predicate, Schema, Struct } from "effect"; | 32 | import { Predicate, Schema, Struct } from "effect"; |
| ... | @@ -67,9 +67,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { | ... | @@ -67,9 +67,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { |
| 67 | * only re-render when the slice they care about changes. | 67 | * only re-render when the slice they care about changes. |
| 68 | */ | 68 | */ |
| 69 | 69 | ||
| 70 | export function useSettings<T extends UnifiedSettings = UnifiedSettings>( | 70 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { |
| 71 | selector?: (s: UnifiedSettings) => T, | ||
| 72 | ): T { | ||
| 73 | const serverSettings = useServerSettings(); | 71 | const serverSettings = useServerSettings(); |
| 74 | const [clientSettings] = useLocalStorage( | 72 | const [clientSettings] = useLocalStorage( |
| 75 | CLIENT_SETTINGS_STORAGE_KEY, | 73 | CLIENT_SETTINGS_STORAGE_KEY, |
| ... | @@ -111,7 +109,7 @@ export function useUpdateSettings() { | ... | @@ -111,7 +109,7 @@ export function useUpdateSettings() { |
| 111 | applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); | 109 | applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); |
| 112 | } | 110 | } |
| 113 | // Fire-and-forget RPC — push will reconcile on success | 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 | if (Object.keys(clientPatch).length > 0) { | 115 | if (Object.keys(clientPatch).length > 0) { |
| ... | @@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void { | ... | @@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void { |
| 239 | // Migrate server-relevant keys via RPC | 237 | // Migrate server-relevant keys via RPC |
| 240 | const serverPatch = buildLegacyServerSettingsMigrationPatch(old); | 238 | const serverPatch = buildLegacyServerSettingsMigrationPatch(old); |
| 241 | if (Object.keys(serverPatch).length > 0) { | 239 | if (Object.keys(serverPatch).length > 0) { |
| 242 | const api = ensureNativeApi(); | 240 | const api = ensureLocalApi(); |
| 243 | void api.server.updateSettings(serverPatch); | 241 | void api.server.updateSettings(serverPatch); |
| 244 | } | 242 | } |
| 245 | 243 |
apps/web/src/hooks/useThreadActions.ts+128-64| ... | @@ -1,42 +1,63 @@ | ... | @@ -1,42 +1,63 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | 1 | import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; | 2 | import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 3 | import { useNavigate, useParams } from "@tanstack/react-router"; | 3 | import { useQueryClient } from "@tanstack/react-query"; |
| 4 | import { useRouter } from "@tanstack/react-router"; | ||
| 4 | import { useCallback } from "react"; | 5 | import { useCallback } from "react"; |
| 5 | 6 | ||
| 6 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; | 7 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; |
| 7 | import { useComposerDraftStore } from "../composerDraftStore"; | 8 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 8 | import { useHandleNewThread } from "./useHandleNewThread"; | 9 | import { useHandleNewThread } from "./useHandleNewThread"; |
| 9 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | 10 | import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; |
| 11 | import { invalidateGitQueries } from "../lib/gitReactQuery"; | ||
| 10 | import { newCommandId } from "../lib/utils"; | 12 | import { newCommandId } from "../lib/utils"; |
| 11 | import { readNativeApi } from "../nativeApi"; | 13 | import { readLocalApi } from "../localApi"; |
| 12 | import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store"; | 14 | import { |
| 15 | selectProjectByRef, | ||
| 16 | selectThreadByRef, | ||
| 17 | selectThreadsForEnvironment, | ||
| 18 | useStore, | ||
| 19 | } from "../store"; | ||
| 13 | import { useTerminalStateStore } from "../terminalStateStore"; | 20 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 21 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | ||
| 14 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 22 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 15 | import { toastManager } from "../components/ui/toast"; | 23 | import { toastManager } from "../components/ui/toast"; |
| 16 | import { useSettings } from "./useSettings"; | 24 | import { useSettings } from "./useSettings"; |
| 17 | 25 | ||
| 18 | export function useThreadActions() { | 26 | export function useThreadActions() { |
| 19 | const appSettings = useSettings(); | 27 | const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); |
| 28 | const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); | ||
| 20 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); | 29 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 21 | const clearProjectDraftThreadById = useComposerDraftStore( | 30 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 22 | (store) => store.clearProjectDraftThreadById, | 31 | (store) => store.clearProjectDraftThreadById, |
| 23 | ); | 32 | ); |
| 24 | const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); | 33 | const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); |
| 25 | const routeThreadId = useParams({ | 34 | const router = useRouter(); |
| 26 | strict: false, | ||
| 27 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | ||
| 28 | }); | ||
| 29 | const navigate = useNavigate(); | ||
| 30 | const { handleNewThread } = useHandleNewThread(); | 35 | const { handleNewThread } = useHandleNewThread(); |
| 31 | const queryClient = useQueryClient(); | 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 | const archiveThread = useCallback( | 54 | const archiveThread = useCallback( |
| 35 | async (threadId: ThreadId) => { | 55 | async (target: ScopedThreadRef) => { |
| 36 | const api = readNativeApi(); | 56 | const api = readEnvironmentApi(target.environmentId); |
| 37 | if (!api) return; | 57 | if (!api) return; |
| 38 | const thread = selectThreadById(threadId)(useStore.getState()); | 58 | const resolved = resolveThreadTarget(target); |
| 39 | if (!thread) return; | 59 | if (!resolved) return; |
| 60 | const { thread, threadRef } = resolved; | ||
| 40 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { | 61 | if (thread.session?.status === "running" && thread.session.activeTurnId != null) { |
| 41 | throw new Error("Cannot archive a running thread."); | 62 | throw new Error("Cannot archive a running thread."); |
| 42 | } | 63 | } |
| ... | @@ -44,48 +65,69 @@ export function useThreadActions() { | ... | @@ -44,48 +65,69 @@ export function useThreadActions() { |
| 44 | await api.orchestration.dispatchCommand({ | 65 | await api.orchestration.dispatchCommand({ |
| 45 | type: "thread.archive", | 66 | type: "thread.archive", |
| 46 | commandId: newCommandId(), | 67 | commandId: newCommandId(), |
| 47 | threadId, | 68 | threadId: threadRef.threadId, |
| 48 | }); | 69 | }); |
| 70 | const currentRouteThreadRef = getCurrentRouteThreadRef(); | ||
| 49 | 71 | ||
| 50 | if (routeThreadId === threadId) { | 72 | if ( |
| 51 | await handleNewThread(thread.projectId); | 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) => { | 82 | const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { |
| 58 | const api = readNativeApi(); | 83 | const api = readEnvironmentApi(target.environmentId); |
| 59 | if (!api) return; | 84 | if (!api) return; |
| 60 | await api.orchestration.dispatchCommand({ | 85 | await api.orchestration.dispatchCommand({ |
| 61 | type: "thread.unarchive", | 86 | type: "thread.unarchive", |
| 62 | commandId: newCommandId(), | 87 | commandId: newCommandId(), |
| 63 | threadId, | 88 | threadId: target.threadId, |
| 64 | }); | 89 | }); |
| 65 | }, []); | 90 | }, []); |
| 66 | 91 | ||
| 67 | const deleteThread = useCallback( | 92 | const deleteThread = useCallback( |
| 68 | async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => { | 93 | async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => { |
| 69 | const api = readNativeApi(); | 94 | const api = readEnvironmentApi(target.environmentId); |
| 70 | if (!api) return; | 95 | if (!api) return; |
| 96 | const resolved = resolveThreadTarget(target); | ||
| 97 | if (!resolved) return; | ||
| 98 | const { thread, threadRef } = resolved; | ||
| 71 | const state = useStore.getState(); | 99 | const state = useStore.getState(); |
| 72 | const threads = selectThreads(state); | 100 | const threads = selectThreadsForEnvironment(state, threadRef.environmentId); |
| 73 | const thread = selectThreadById(threadId)(state); | 101 | const threadProject = selectProjectByRef(state, { |
| 74 | if (!thread) return; | 102 | environmentId: threadRef.environmentId, |
| 75 | const threadProject = selectProjectById(thread.projectId)(state); | 103 | projectId: thread.projectId, |
| 76 | const deletedIds = opts.deletedThreadIds; | 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 | const survivingThreads = | 114 | const survivingThreads = |
| 78 | deletedIds && deletedIds.size > 0 | 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 | : threads; | 117 | : threads; |
| 81 | const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); | 118 | const orphanedWorktreePath = getOrphanedWorktreePathForThread( |
| 119 | survivingThreads, | ||
| 120 | threadRef.threadId, | ||
| 121 | ); | ||
| 82 | const displayWorktreePath = orphanedWorktreePath | 122 | const displayWorktreePath = orphanedWorktreePath |
| 83 | ? formatWorktreePathForDisplay(orphanedWorktreePath) | 123 | ? formatWorktreePathForDisplay(orphanedWorktreePath) |
| 84 | : null; | 124 | : null; |
| 85 | const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; | 125 | const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; |
| 126 | const localApi = readLocalApi(); | ||
| 86 | const shouldDeleteWorktree = | 127 | const shouldDeleteWorktree = |
| 87 | canDeleteWorktree && | 128 | canDeleteWorktree && |
| 88 | (await api.dialogs.confirm( | 129 | localApi && |
| 130 | (await localApi.dialogs.confirm( | ||
| 89 | [ | 131 | [ |
| 90 | "This thread is the only one linked to this worktree:", | 132 | "This thread is the only one linked to this worktree:", |
| 91 | displayWorktreePath ?? orphanedWorktreePath, | 133 | displayWorktreePath ?? orphanedWorktreePath, |
| ... | @@ -99,44 +141,60 @@ export function useThreadActions() { | ... | @@ -99,44 +141,60 @@ export function useThreadActions() { |
| 99 | .dispatchCommand({ | 141 | .dispatchCommand({ |
| 100 | type: "thread.session.stop", | 142 | type: "thread.session.stop", |
| 101 | commandId: newCommandId(), | 143 | commandId: newCommandId(), |
| 102 | threadId, | 144 | threadId: threadRef.threadId, |
| 103 | createdAt: new Date().toISOString(), | 145 | createdAt: new Date().toISOString(), |
| 104 | }) | 146 | }) |
| 105 | .catch(() => undefined); | 147 | .catch(() => undefined); |
| 106 | } | 148 | } |
| 107 | 149 | ||
| 108 | try { | 150 | try { |
| 109 | await api.terminal.close({ threadId, deleteHistory: true }); | 151 | await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); |
| 110 | } catch { | 152 | } catch { |
| 111 | // Terminal may already be closed. | 153 | // Terminal may already be closed. |
| 112 | } | 154 | } |
| 113 | 155 | ||
| 114 | const deletedThreadIds = opts.deletedThreadIds ?? new Set<ThreadId>(); | 156 | const deletedThreadIds = deletedIds ?? new Set<ThreadId>(); |
| 115 | const shouldNavigateToFallback = routeThreadId === threadId; | 157 | const currentRouteThreadRef = getCurrentRouteThreadRef(); |
| 158 | const shouldNavigateToFallback = | ||
| 159 | currentRouteThreadRef?.threadId === threadRef.threadId && | ||
| 160 | currentRouteThreadRef.environmentId === threadRef.environmentId; | ||
| 116 | const fallbackThreadId = getFallbackThreadIdAfterDelete({ | 161 | const fallbackThreadId = getFallbackThreadIdAfterDelete({ |
| 117 | threads, | 162 | threads, |
| 118 | deletedThreadId: threadId, | 163 | deletedThreadId: threadRef.threadId, |
| 119 | deletedThreadIds, | 164 | deletedThreadIds, |
| 120 | sortOrder: appSettings.sidebarThreadSortOrder, | 165 | sortOrder: sidebarThreadSortOrder, |
| 121 | }); | 166 | }); |
| 122 | await api.orchestration.dispatchCommand({ | 167 | await api.orchestration.dispatchCommand({ |
| 123 | type: "thread.delete", | 168 | type: "thread.delete", |
| 124 | commandId: newCommandId(), | 169 | commandId: newCommandId(), |
| 125 | threadId, | 170 | threadId: threadRef.threadId, |
| 126 | }); | 171 | }); |
| 127 | clearComposerDraftForThread(threadId); | 172 | clearComposerDraftForThread(threadRef); |
| 128 | clearProjectDraftThreadById(thread.projectId, thread.id); | 173 | clearProjectDraftThreadById( |
| 129 | clearTerminalState(threadId); | 174 | scopeProjectRef(threadRef.environmentId, thread.projectId), |
| 175 | threadRef, | ||
| 176 | ); | ||
| 177 | clearTerminalState(threadRef); | ||
| 130 | 178 | ||
| 131 | if (shouldNavigateToFallback) { | 179 | if (shouldNavigateToFallback) { |
| 132 | if (fallbackThreadId) { | 180 | if (fallbackThreadId) { |
| 133 | await navigate({ | 181 | const fallbackThread = selectThreadByRef( |
| 134 | to: "/$threadId", | 182 | useStore.getState(), |
| 135 | params: { threadId: fallbackThreadId }, | 183 | scopeThreadRef(threadRef.environmentId, fallbackThreadId), |
| 136 | replace: true, | 184 | ); |
| 137 | }); | 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 | } else { | 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,15 +203,18 @@ export function useThreadActions() { |
| 145 | } | 203 | } |
| 146 | 204 | ||
| 147 | try { | 205 | try { |
| 148 | await removeWorktreeMutation.mutateAsync({ | 206 | await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({ |
| 149 | cwd: threadProject.cwd, | 207 | cwd: threadProject.cwd, |
| 150 | path: orphanedWorktreePath, | 208 | path: orphanedWorktreePath, |
| 151 | force: true, | 209 | force: true, |
| 152 | }); | 210 | }); |
| 211 | await invalidateGitQueries(queryClient, { | ||
| 212 | environmentId: threadRef.environmentId, | ||
| 213 | }); | ||
| 153 | } catch (error) { | 214 | } catch (error) { |
| 154 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; | 215 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; |
| 155 | console.error("Failed to remove orphaned worktree after thread deletion", { | 216 | console.error("Failed to remove orphaned worktree after thread deletion", { |
| 156 | threadId, | 217 | threadId: threadRef.threadId, |
| 157 | projectCwd: threadProject.cwd, | 218 | projectCwd: threadProject.cwd, |
| 158 | worktreePath: orphanedWorktreePath, | 219 | worktreePath: orphanedWorktreePath, |
| 159 | error, | 220 | error, |
| ... | @@ -169,22 +230,25 @@ export function useThreadActions() { | ... | @@ -169,22 +230,25 @@ export function useThreadActions() { |
| 169 | clearComposerDraftForThread, | 230 | clearComposerDraftForThread, |
| 170 | clearProjectDraftThreadById, | 231 | clearProjectDraftThreadById, |
| 171 | clearTerminalState, | 232 | clearTerminalState, |
| 172 | appSettings.sidebarThreadSortOrder, | 233 | getCurrentRouteThreadRef, |
| 173 | navigate, | 234 | router, |
| 174 | removeWorktreeMutation, | 235 | queryClient, |
| 175 | routeThreadId, | 236 | resolveThreadTarget, |
| 237 | sidebarThreadSortOrder, | ||
| 176 | ], | 238 | ], |
| 177 | ); | 239 | ); |
| 178 | 240 | ||
| 179 | const confirmAndDeleteThread = useCallback( | 241 | const confirmAndDeleteThread = useCallback( |
| 180 | async (threadId: ThreadId) => { | 242 | async (target: ScopedThreadRef) => { |
| 181 | const api = readNativeApi(); | 243 | const api = readEnvironmentApi(target.environmentId); |
| 182 | if (!api) return; | 244 | if (!api) return; |
| 183 | const thread = selectThreadById(threadId)(useStore.getState()); | 245 | const localApi = readLocalApi(); |
| 184 | if (!thread) return; | 246 | const resolved = resolveThreadTarget(target); |
| 247 | if (!resolved) return; | ||
| 248 | const { thread } = resolved; | ||
| 185 | 249 | ||
| 186 | if (appSettings.confirmThreadDelete) { | 250 | if (confirmThreadDelete && localApi) { |
| 187 | const confirmed = await api.dialogs.confirm( | 251 | const confirmed = await localApi.dialogs.confirm( |
| 188 | [ | 252 | [ |
| 189 | `Delete thread "${thread.title}"?`, | 253 | `Delete thread "${thread.title}"?`, |
| 190 | "This permanently clears conversation history for this thread.", | 254 | "This permanently clears conversation history for this thread.", |
| ... | @@ -195,9 +259,9 @@ export function useThreadActions() { | ... | @@ -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 | return { | 267 | return { |
apps/web/src/lib/gitReactQuery.test.ts+30-13| ... | @@ -1,16 +1,17 @@ | ... | @@ -1,16 +1,17 @@ |
| 1 | import { QueryClient } from "@tanstack/react-query"; | 1 | import { QueryClient } from "@tanstack/react-query"; |
| 2 | import { describe, expect, it, vi } from "vitest"; | 2 | import { describe, expect, it, vi } from "vitest"; |
| 3 | 3 | ||
| 4 | vi.mock("../nativeApi", () => ({ | 4 | vi.mock("../environmentApi", () => ({ |
| 5 | ensureNativeApi: vi.fn(), | 5 | ensureEnvironmentApi: vi.fn(), |
| 6 | })); | 6 | })); |
| 7 | 7 | ||
| 8 | vi.mock("../wsRpcClient", () => ({ | 8 | vi.mock("../wsRpcClient", () => ({ |
| 9 | getWsRpcClient: vi.fn(), | 9 | getWsRpcClient: vi.fn(), |
| 10 | getWsRpcClientForEnvironment: vi.fn(), | ||
| 10 | })); | 11 | })); |
| 11 | 12 | ||
| 12 | import type { InfiniteData } from "@tanstack/react-query"; | 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 | import { | 16 | import { |
| 16 | gitBranchSearchInfiniteQueryOptions, | 17 | gitBranchSearchInfiniteQueryOptions, |
| ... | @@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = { | ... | @@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = { |
| 33 | pages: [BRANCH_QUERY_RESULT], | 34 | pages: [BRANCH_QUERY_RESULT], |
| 34 | pageParams: [0], | 35 | pageParams: [0], |
| 35 | }; | 36 | }; |
| 37 | const ENVIRONMENT_A = EnvironmentId.makeUnsafe("environment-a"); | ||
| 38 | const ENVIRONMENT_B = EnvironmentId.makeUnsafe("environment-b"); | ||
| 36 | 39 | ||
| 37 | describe("gitMutationKeys", () => { | 40 | describe("gitMutationKeys", () => { |
| 38 | it("scopes stacked action keys by cwd", () => { | 41 | it("scopes stacked action keys by cwd", () => { |
| 39 | expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( | 42 | expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual( |
| 40 | gitMutationKeys.runStackedAction("/repo/b"), | 43 | gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"), |
| 41 | ); | 44 | ); |
| 42 | }); | 45 | }); |
| 43 | 46 | ||
| 44 | it("scopes pull keys by cwd", () => { | 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 | it("scopes pull request thread preparation keys by cwd", () => { | 53 | it("scopes pull request thread preparation keys by cwd", () => { |
| 49 | expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual( | 54 | expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual( |
| 50 | gitMutationKeys.preparePullRequestThread("/repo/b"), | 55 | gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"), |
| 51 | ); | 56 | ); |
| 52 | }); | 57 | }); |
| 53 | }); | 58 | }); |
| ... | @@ -57,23 +62,31 @@ describe("git mutation options", () => { | ... | @@ -57,23 +62,31 @@ describe("git mutation options", () => { |
| 57 | 62 | ||
| 58 | it("attaches cwd-scoped mutation key for runStackedAction", () => { | 63 | it("attaches cwd-scoped mutation key for runStackedAction", () => { |
| 59 | const options = gitRunStackedActionMutationOptions({ | 64 | const options = gitRunStackedActionMutationOptions({ |
| 65 | environmentId: ENVIRONMENT_A, | ||
| 60 | cwd: "/repo/a", | 66 | cwd: "/repo/a", |
| 61 | queryClient, | 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 | it("attaches cwd-scoped mutation key for pull", () => { | 72 | it("attaches cwd-scoped mutation key for pull", () => { |
| 67 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); | 73 | const options = gitPullMutationOptions({ |
| 68 | expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); | 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 | it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { | 81 | it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { |
| 72 | const options = gitPreparePullRequestThreadMutationOptions({ | 82 | const options = gitPreparePullRequestThreadMutationOptions({ |
| 83 | environmentId: ENVIRONMENT_A, | ||
| 73 | cwd: "/repo/a", | 84 | cwd: "/repo/a", |
| 74 | queryClient, | 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,6 +96,7 @@ describe("invalidateGitQueries", () => { |
| 83 | 96 | ||
| 84 | queryClient.setQueryData( | 97 | queryClient.setQueryData( |
| 85 | gitBranchSearchInfiniteQueryOptions({ | 98 | gitBranchSearchInfiniteQueryOptions({ |
| 99 | environmentId: ENVIRONMENT_A, | ||
| 86 | cwd: "/repo/a", | 100 | cwd: "/repo/a", |
| 87 | query: "feature", | 101 | query: "feature", |
| 88 | }).queryKey, | 102 | }).queryKey, |
| ... | @@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => { | ... | @@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => { |
| 90 | ); | 104 | ); |
| 91 | queryClient.setQueryData( | 105 | queryClient.setQueryData( |
| 92 | gitBranchSearchInfiniteQueryOptions({ | 106 | gitBranchSearchInfiniteQueryOptions({ |
| 107 | environmentId: ENVIRONMENT_B, | ||
| 93 | cwd: "/repo/b", | 108 | cwd: "/repo/b", |
| 94 | query: "feature", | 109 | query: "feature", |
| 95 | }).queryKey, | 110 | }).queryKey, |
| 96 | BRANCH_SEARCH_RESULT, | 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 | expect( | 116 | expect( |
| 102 | queryClient.getQueryState( | 117 | queryClient.getQueryState( |
| 103 | gitBranchSearchInfiniteQueryOptions({ | 118 | gitBranchSearchInfiniteQueryOptions({ |
| 119 | environmentId: ENVIRONMENT_A, | ||
| 104 | cwd: "/repo/a", | 120 | cwd: "/repo/a", |
| 105 | query: "feature", | 121 | query: "feature", |
| 106 | }).queryKey, | 122 | }).queryKey, |
| ... | @@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => { | ... | @@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => { |
| 109 | expect( | 125 | expect( |
| 110 | queryClient.getQueryState( | 126 | queryClient.getQueryState( |
| 111 | gitBranchSearchInfiniteQueryOptions({ | 127 | gitBranchSearchInfiniteQueryOptions({ |
| 128 | environmentId: ENVIRONMENT_B, | ||
| 112 | cwd: "/repo/b", | 129 | cwd: "/repo/b", |
| 113 | query: "feature", | 130 | query: "feature", |
| 114 | }).queryKey, | 131 | }).queryKey, |
apps/web/src/lib/gitReactQuery.ts+105-53| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | ||
| 2 | type GitActionProgressEvent, | 3 | type GitActionProgressEvent, |
| 3 | type GitStackedAction, | 4 | type GitStackedAction, |
| 4 | type ThreadId, | 5 | type ThreadId, |
| ... | @@ -9,8 +10,8 @@ import { | ... | @@ -9,8 +10,8 @@ import { |
| 9 | queryOptions, | 10 | queryOptions, |
| 10 | type QueryClient, | 11 | type QueryClient, |
| 11 | } from "@tanstack/react-query"; | 12 | } from "@tanstack/react-query"; |
| 12 | import { ensureNativeApi } from "../nativeApi"; | 13 | import { ensureEnvironmentApi } from "../environmentApi"; |
| 13 | import { getWsRpcClient } from "../wsRpcClient"; | 14 | import { getWsRpcClientForEnvironment } from "../wsRpcClient"; |
| 14 | 15 | ||
| 15 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; | 16 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; |
| 16 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; | 17 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; |
| ... | @@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100; | ... | @@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100; |
| 18 | 19 | ||
| 19 | export const gitQueryKeys = { | 20 | export const gitQueryKeys = { |
| 20 | all: ["git"] as const, | 21 | all: ["git"] as const, |
| 21 | branches: (cwd: string | null) => ["git", "branches", cwd] as const, | 22 | branches: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 22 | branchSearch: (cwd: string | null, query: string) => | 23 | ["git", "branches", environmentId ?? null, cwd] as const, |
| 23 | ["git", "branches", cwd, "search", query] 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 | export const gitMutationKeys = { | 28 | export const gitMutationKeys = { |
| 27 | init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const, | 29 | init: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 28 | checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, | 30 | ["git", "mutation", "init", environmentId ?? null, cwd] as const, |
| 29 | runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, | 31 | checkout: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 30 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, | 32 | ["git", "mutation", "checkout", environmentId ?? null, cwd] as const, |
| 31 | preparePullRequestThread: (cwd: string | null) => | 33 | runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 32 | ["git", "mutation", "prepare-pull-request-thread", cwd] as const, | 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 | const cwd = input?.cwd ?? null; | 46 | const cwd = input?.cwd ?? null; |
| 37 | if (cwd !== null) { | 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 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); | 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 | if (cwd === null) { | 59 | if (cwd === null) { |
| 46 | return Promise.resolve(); | 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 | export function gitBranchSearchInfiniteQueryOptions(input: { | 66 | export function gitBranchSearchInfiniteQueryOptions(input: { |
| 67 | environmentId: EnvironmentId | null; | ||
| 53 | cwd: string | null; | 68 | cwd: string | null; |
| 54 | query: string; | 69 | query: string; |
| 55 | enabled?: boolean; | 70 | enabled?: boolean; |
| ... | @@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: { | ... | @@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: { |
| 57 | const normalizedQuery = input.query.trim(); | 72 | const normalizedQuery = input.query.trim(); |
| 58 | 73 | ||
| 59 | return infiniteQueryOptions({ | 74 | return infiniteQueryOptions({ |
| 60 | queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery), | 75 | queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), |
| 61 | initialPageParam: 0, | 76 | initialPageParam: 0, |
| 62 | queryFn: async ({ pageParam }) => { | 77 | queryFn: async ({ pageParam }) => { |
| 63 | const api = ensureNativeApi(); | ||
| 64 | if (!input.cwd) throw new Error("Git branches are unavailable."); | 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 | return api.git.listBranches({ | 81 | return api.git.listBranches({ |
| 66 | cwd: input.cwd, | 82 | cwd: input.cwd, |
| 67 | ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), | 83 | ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), |
| ... | @@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: { | ... | @@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: { |
| 79 | } | 95 | } |
| 80 | 96 | ||
| 81 | export function gitResolvePullRequestQueryOptions(input: { | 97 | export function gitResolvePullRequestQueryOptions(input: { |
| 98 | environmentId: EnvironmentId | null; | ||
| 82 | cwd: string | null; | 99 | cwd: string | null; |
| 83 | reference: string | null; | 100 | reference: string | null; |
| 84 | }) { | 101 | }) { |
| 85 | return queryOptions({ | 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 | queryFn: async () => { | 110 | queryFn: async () => { |
| 88 | const api = ensureNativeApi(); | 111 | if (!input.cwd || !input.reference || !input.environmentId) { |
| 89 | if (!input.cwd || !input.reference) { | ||
| 90 | throw new Error("Pull request lookup is unavailable."); | 112 | throw new Error("Pull request lookup is unavailable."); |
| 91 | } | 113 | } |
| 114 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 92 | return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); | 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 | staleTime: 30_000, | 118 | staleTime: 30_000, |
| 96 | refetchOnWindowFocus: false, | 119 | refetchOnWindowFocus: false, |
| 97 | refetchOnReconnect: false, | 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 | return mutationOptions({ | 129 | return mutationOptions({ |
| 103 | mutationKey: gitMutationKeys.init(input.cwd), | 130 | mutationKey: gitMutationKeys.init(input.environmentId, input.cwd), |
| 104 | mutationFn: async () => { | 131 | mutationFn: async () => { |
| 105 | const api = ensureNativeApi(); | 132 | if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); |
| 106 | if (!input.cwd) throw new Error("Git init is unavailable."); | 133 | const api = ensureEnvironmentApi(input.environmentId); |
| 107 | return api.git.init({ cwd: input.cwd }); | 134 | return api.git.init({ cwd: input.cwd }); |
| 108 | }, | 135 | }, |
| 109 | onSettled: async () => { | 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 | export function gitCheckoutMutationOptions(input: { | 142 | export function gitCheckoutMutationOptions(input: { |
| 143 | environmentId: EnvironmentId | null; | ||
| 116 | cwd: string | null; | 144 | cwd: string | null; |
| 117 | queryClient: QueryClient; | 145 | queryClient: QueryClient; |
| 118 | }) { | 146 | }) { |
| 119 | return mutationOptions({ | 147 | return mutationOptions({ |
| 120 | mutationKey: gitMutationKeys.checkout(input.cwd), | 148 | mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd), |
| 121 | mutationFn: async (branch: string) => { | 149 | mutationFn: async (branch: string) => { |
| 122 | const api = ensureNativeApi(); | 150 | if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable."); |
| 123 | if (!input.cwd) throw new Error("Git checkout is unavailable."); | 151 | const api = ensureEnvironmentApi(input.environmentId); |
| 124 | return api.git.checkout({ cwd: input.cwd, branch }); | 152 | return api.git.checkout({ cwd: input.cwd, branch }); |
| 125 | }, | 153 | }, |
| 126 | onSettled: async () => { | 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 | export function gitRunStackedActionMutationOptions(input: { | 160 | export function gitRunStackedActionMutationOptions(input: { |
| 161 | environmentId: EnvironmentId | null; | ||
| 133 | cwd: string | null; | 162 | cwd: string | null; |
| 134 | queryClient: QueryClient; | 163 | queryClient: QueryClient; |
| 135 | }) { | 164 | }) { |
| 136 | return mutationOptions({ | 165 | return mutationOptions({ |
| 137 | mutationKey: gitMutationKeys.runStackedAction(input.cwd), | 166 | mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd), |
| 138 | mutationFn: async ({ | 167 | mutationFn: async ({ |
| 139 | actionId, | 168 | actionId, |
| 140 | action, | 169 | action, |
| ... | @@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: { |
| 150 | filePaths?: string[]; | 179 | filePaths?: string[]; |
| 151 | onProgress?: (event: GitActionProgressEvent) => void; | 180 | onProgress?: (event: GitActionProgressEvent) => void; |
| 152 | }) => { | 181 | }) => { |
| 153 | if (!input.cwd) throw new Error("Git action is unavailable."); | 182 | if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable."); |
| 154 | return getWsRpcClient().git.runStackedAction( | 183 | return getWsRpcClientForEnvironment(input.environmentId).git.runStackedAction( |
| 155 | { | 184 | { |
| 156 | action, | 185 | action, |
| 157 | actionId, | 186 | actionId, |
| ... | @@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: { |
| 164 | ); | 193 | ); |
| 165 | }, | 194 | }, |
| 166 | onSuccess: async () => { | 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 | return mutationOptions({ | 206 | return mutationOptions({ |
| 174 | mutationKey: gitMutationKeys.pull(input.cwd), | 207 | mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd), |
| 175 | mutationFn: async () => { | 208 | mutationFn: async () => { |
| 176 | const api = ensureNativeApi(); | 209 | if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); |
| 177 | if (!input.cwd) throw new Error("Git pull is unavailable."); | 210 | const api = ensureEnvironmentApi(input.environmentId); |
| 178 | return api.git.pull({ cwd: input.cwd }); | 211 | return api.git.pull({ cwd: input.cwd }); |
| 179 | }, | 212 | }, |
| 180 | onSuccess: async () => { | 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 | return mutationOptions({ | 223 | return mutationOptions({ |
| 188 | mutationKey: ["git", "mutation", "create-worktree"] as const, | 224 | mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, |
| 189 | mutationFn: ( | 225 | mutationFn: ( |
| 190 | args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["createWorktree"]>[0], | 226 | args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["createWorktree"]>[0], |
| 191 | ) => ensureNativeApi().git.createWorktree(args), | 227 | ) => { |
| 228 | if (!input.environmentId) { | ||
| 229 | throw new Error("Worktree creation is unavailable."); | ||
| 230 | } | ||
| 231 | return ensureEnvironmentApi(input.environmentId).git.createWorktree(args); | ||
| 232 | }, | ||
| 192 | onSuccess: async () => { | 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 | return mutationOptions({ | 243 | return mutationOptions({ |
| 200 | mutationKey: ["git", "mutation", "remove-worktree"] as const, | 244 | mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, |
| 201 | mutationFn: ( | 245 | mutationFn: ( |
| 202 | args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["removeWorktree"]>[0], | 246 | args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["removeWorktree"]>[0], |
| 203 | ) => ensureNativeApi().git.removeWorktree(args), | 247 | ) => { |
| 248 | if (!input.environmentId) { | ||
| 249 | throw new Error("Worktree removal is unavailable."); | ||
| 250 | } | ||
| 251 | return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args); | ||
| 252 | }, | ||
| 204 | onSuccess: async () => { | 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 | export function gitPreparePullRequestThreadMutationOptions(input: { | 259 | export function gitPreparePullRequestThreadMutationOptions(input: { |
| 260 | environmentId: EnvironmentId | null; | ||
| 211 | cwd: string | null; | 261 | cwd: string | null; |
| 212 | queryClient: QueryClient; | 262 | queryClient: QueryClient; |
| 213 | }) { | 263 | }) { |
| 214 | return mutationOptions({ | 264 | return mutationOptions({ |
| 215 | mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), | 265 | mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd), |
| 216 | mutationFn: async (args: { | 266 | mutationFn: async (args: { |
| 217 | reference: string; | 267 | reference: string; |
| 218 | mode: "local" | "worktree"; | 268 | mode: "local" | "worktree"; |
| 219 | threadId?: ThreadId; | 269 | threadId?: ThreadId; |
| 220 | }) => { | 270 | }) => { |
| 221 | const api = ensureNativeApi(); | 271 | if (!input.cwd || !input.environmentId) { |
| 222 | if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); | 272 | throw new Error("Pull request thread preparation is unavailable."); |
| 273 | } | ||
| 274 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 223 | return api.git.preparePullRequestThread({ | 275 | return api.git.preparePullRequestThread({ |
| 224 | cwd: input.cwd, | 276 | cwd: input.cwd, |
| 225 | reference: args.reference, | 277 | reference: args.reference, |
| ... | @@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: { | ... | @@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: { |
| 228 | }); | 280 | }); |
| 229 | }, | 281 | }, |
| 230 | onSuccess: async () => { | 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,4 +1,4 @@ |
| 1 | import type { GitStatusResult } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| ... | @@ -16,6 +16,10 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even | ... | @@ -16,6 +16,10 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); | 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 | const BASE_STATUS: GitStatusResult = { | 24 | const BASE_STATUS: GitStatusResult = { |
| 21 | isRepo: true, | 25 | isRepo: true, |
| ... | @@ -55,7 +59,7 @@ afterEach(() => { | ... | @@ -55,7 +59,7 @@ afterEach(() => { |
| 55 | 59 | ||
| 56 | describe("gitStatusState", () => { | 60 | describe("gitStatusState", () => { |
| 57 | it("starts fresh cwd state in a pending state", () => { | 61 | it("starts fresh cwd state in a pending state", () => { |
| 58 | expect(getGitStatusSnapshot("/fresh")).toEqual({ | 62 | expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({ |
| 59 | data: null, | 63 | data: null, |
| 60 | error: null, | 64 | error: null, |
| 61 | cause: null, | 65 | cause: null, |
| ... | @@ -64,11 +68,11 @@ describe("gitStatusState", () => { | ... | @@ -64,11 +68,11 @@ describe("gitStatusState", () => { |
| 64 | }); | 68 | }); |
| 65 | 69 | ||
| 66 | it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { | 70 | it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { |
| 67 | const releaseA = watchGitStatus("/repo", gitClient); | 71 | const releaseA = watchGitStatus(TARGET, gitClient); |
| 68 | const releaseB = watchGitStatus("/repo", gitClient); | 72 | const releaseB = watchGitStatus(TARGET, gitClient); |
| 69 | 73 | ||
| 70 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); | 74 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); |
| 71 | expect(getGitStatusSnapshot("/repo")).toEqual({ | 75 | expect(getGitStatusSnapshot(TARGET)).toEqual({ |
| 72 | data: null, | 76 | data: null, |
| 73 | error: null, | 77 | error: null, |
| 74 | cause: null, | 78 | cause: null, |
| ... | @@ -77,7 +81,7 @@ describe("gitStatusState", () => { | ... | @@ -77,7 +81,7 @@ describe("gitStatusState", () => { |
| 77 | 81 | ||
| 78 | emitGitStatus(BASE_STATUS); | 82 | emitGitStatus(BASE_STATUS); |
| 79 | 83 | ||
| 80 | expect(getGitStatusSnapshot("/repo")).toEqual({ | 84 | expect(getGitStatusSnapshot(TARGET)).toEqual({ |
| 81 | data: BASE_STATUS, | 85 | data: BASE_STATUS, |
| 82 | error: null, | 86 | error: null, |
| 83 | cause: null, | 87 | cause: null, |
| ... | @@ -92,15 +96,15 @@ describe("gitStatusState", () => { | ... | @@ -92,15 +96,15 @@ describe("gitStatusState", () => { |
| 92 | }); | 96 | }); |
| 93 | 97 | ||
| 94 | it("refreshes git status through the unary RPC without restarting the stream", async () => { | 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 | emitGitStatus(BASE_STATUS); | 101 | emitGitStatus(BASE_STATUS); |
| 98 | const refreshed = await refreshGitStatus("/repo", gitClient); | 102 | const refreshed = await refreshGitStatus(TARGET, gitClient); |
| 99 | 103 | ||
| 100 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); | 104 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); |
| 101 | expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | 105 | expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); |
| 102 | expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); | 106 | expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); |
| 103 | expect(getGitStatusSnapshot("/repo")).toEqual({ | 107 | expect(getGitStatusSnapshot(TARGET)).toEqual({ |
| 104 | data: BASE_STATUS, | 108 | data: BASE_STATUS, |
| 105 | error: null, | 109 | error: null, |
| 106 | cause: null, | 110 | cause: null, |
| ... | @@ -109,4 +113,38 @@ describe("gitStatusState", () => { | ... | @@ -109,4 +113,38 @@ describe("gitStatusState", () => { |
| 109 | 113 | ||
| 110 | release(); | 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,11 +1,15 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 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 | import { Cause } from "effect"; | 7 | import { Cause } from "effect"; |
| 4 | import { Atom } from "effect/unstable/reactivity"; | 8 | import { Atom } from "effect/unstable/reactivity"; |
| 5 | import { useEffect } from "react"; | 9 | import { useEffect } from "react"; |
| 6 | 10 | ||
| 7 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 8 | import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient"; | 12 | import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient"; |
| 9 | 13 | ||
| 10 | export type GitStatusStreamError = GitManagerServiceError; | 14 | export type GitStatusStreamError = GitManagerServiceError; |
| 11 | 15 | ||
| ... | @@ -23,6 +27,11 @@ interface WatchedGitStatus { | ... | @@ -23,6 +27,11 @@ interface WatchedGitStatus { |
| 23 | unsubscribe: () => void; | 27 | unsubscribe: () => void; |
| 24 | } | 28 | } |
| 25 | 29 | ||
| 30 | export interface GitStatusTarget { | ||
| 31 | readonly environmentId: EnvironmentId | null; | ||
| 32 | readonly cwd: string | null; | ||
| 33 | } | ||
| 34 | |||
| 26 | const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({ | 35 | const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({ |
| 27 | data: null, | 36 | data: null, |
| 28 | error: null, | 37 | error: null, |
| ... | @@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( | ... | @@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( |
| 40 | 49 | ||
| 41 | const NOOP: () => void = () => undefined; | 50 | const NOOP: () => void = () => undefined; |
| 42 | const watchedGitStatuses = new Map<string, WatchedGitStatus>(); | 51 | const watchedGitStatuses = new Map<string, WatchedGitStatus>(); |
| 43 | const knownGitStatusCwds = new Set<string>(); | 52 | const knownGitStatusKeys = new Set<string>(); |
| 44 | const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>(); | 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 | const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; | 56 | const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; |
| 48 | 57 | ||
| 49 | let sharedGitStatusClient: GitStatusClient | null = null; | 58 | const gitStatusStateAtom = Atom.family((key: string) => { |
| 50 | 59 | knownGitStatusKeys.add(key); | |
| 51 | const gitStatusStateAtom = Atom.family((cwd: string) => { | ||
| 52 | knownGitStatusCwds.add(cwd); | ||
| 53 | return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( | 60 | return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( |
| 54 | Atom.keepAlive, | 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 { | 66 | function getGitStatusTargetKey(target: GitStatusTarget): string | null { |
| 60 | if (cwd === 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 | return EMPTY_GIT_STATUS_STATE; | 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 | export function watchGitStatus( | 90 | export function watchGitStatus( |
| 68 | cwd: string | null, | 91 | target: GitStatusTarget, |
| 69 | client: GitStatusClient = getWsRpcClient().git, | 92 | client: GitStatusClient = resolveGitStatusClient(target), |
| 70 | ): () => void { | 93 | ): () => void { |
| 71 | if (cwd === null) { | 94 | const targetKey = getGitStatusTargetKey(target); |
| 95 | if (targetKey === null) { | ||
| 72 | return NOOP; | 96 | return NOOP; |
| 73 | } | 97 | } |
| 74 | 98 | ||
| 75 | ensureGitStatusClient(client); | 99 | const watched = watchedGitStatuses.get(targetKey); |
| 76 | |||
| 77 | const watched = watchedGitStatuses.get(cwd); | ||
| 78 | if (watched) { | 100 | if (watched) { |
| 79 | watched.refCount += 1; | 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 | refCount: 1, | 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 | export function refreshGitStatus( | 113 | export function refreshGitStatus( |
| 92 | cwd: string | null, | 114 | target: GitStatusTarget, |
| 93 | client: GitStatusClient = getWsRpcClient().git, | 115 | client: GitStatusClient = resolveGitStatusClient(target), |
| 94 | ): Promise<GitStatusResult | null> { | 116 | ): Promise<GitStatusResult | null> { |
| 95 | if (cwd === null) { | 117 | const targetKey = getGitStatusTargetKey(target); |
| 118 | if (targetKey === null || target.cwd === null) { | ||
| 96 | return Promise.resolve(null); | 119 | return Promise.resolve(null); |
| 97 | } | 120 | } |
| 98 | 121 | ||
| 99 | ensureGitStatusClient(client); | 122 | const currentInFlight = gitStatusRefreshInFlight.get(targetKey); |
| 100 | |||
| 101 | const currentInFlight = gitStatusRefreshInFlight.get(cwd); | ||
| 102 | if (currentInFlight) { | 123 | if (currentInFlight) { |
| 103 | return currentInFlight; | 124 | return currentInFlight; |
| 104 | } | 125 | } |
| 105 | 126 | ||
| 106 | const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0; | 127 | const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0; |
| 107 | if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { | 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()); | 132 | gitStatusLastRefreshAtByKey.set(targetKey, Date.now()); |
| 112 | const refreshPromise = client.refreshStatus({ cwd }).finally(() => { | 133 | const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => { |
| 113 | gitStatusRefreshInFlight.delete(cwd); | 134 | gitStatusRefreshInFlight.delete(targetKey); |
| 114 | }); | 135 | }); |
| 115 | gitStatusRefreshInFlight.set(cwd, refreshPromise); | 136 | gitStatusRefreshInFlight.set(targetKey, refreshPromise); |
| 116 | return refreshPromise; | 137 | return refreshPromise; |
| 117 | } | 138 | } |
| 118 | 139 | ||
| ... | @@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void { | ... | @@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void { |
| 122 | } | 143 | } |
| 123 | watchedGitStatuses.clear(); | 144 | watchedGitStatuses.clear(); |
| 124 | gitStatusRefreshInFlight.clear(); | 145 | gitStatusRefreshInFlight.clear(); |
| 125 | gitStatusLastRefreshAtByCwd.clear(); | 146 | gitStatusLastRefreshAtByKey.clear(); |
| 126 | sharedGitStatusClient = null; | ||
| 127 | 147 | ||
| 128 | for (const cwd of knownGitStatusCwds) { | 148 | for (const key of knownGitStatusKeys) { |
| 129 | appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE); | 149 | appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE); |
| 130 | } | 150 | } |
| 131 | knownGitStatusCwds.clear(); | 151 | knownGitStatusKeys.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; | ||
| 139 | } | 152 | } |
| 140 | 153 | ||
| 141 | function ensureGitStatusClient(client: GitStatusClient): void { | 154 | export function useGitStatus(target: GitStatusTarget): GitStatusState { |
| 142 | if (sharedGitStatusClient === client) { | 155 | const targetKey = getGitStatusTargetKey(target); |
| 143 | return; | 156 | useEffect( |
| 144 | } | 157 | () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }), |
| 145 | 158 | [target.environmentId, target.cwd], | |
| 146 | if (sharedGitStatusClient !== null) { | 159 | ); |
| 147 | resetLiveGitStatusSubscriptions(); | ||
| 148 | } | ||
| 149 | |||
| 150 | sharedGitStatusClient = client; | ||
| 151 | } | ||
| 152 | 160 | ||
| 153 | function resetLiveGitStatusSubscriptions(): void { | 161 | const state = useAtomValue( |
| 154 | for (const watched of watchedGitStatuses.values()) { | 162 | targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM, |
| 155 | watched.unsubscribe(); | 163 | ); |
| 156 | } | 164 | return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; |
| 157 | watchedGitStatuses.clear(); | ||
| 158 | } | 165 | } |
| 159 | 166 | ||
| 160 | function unwatchGitStatus(cwd: string): void { | 167 | function unwatchGitStatus(targetKey: string): void { |
| 161 | const watched = watchedGitStatuses.get(cwd); | 168 | const watched = watchedGitStatuses.get(targetKey); |
| 162 | if (!watched) { | 169 | if (!watched) { |
| 163 | return; | 170 | return; |
| 164 | } | 171 | } |
| ... | @@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void { | ... | @@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void { |
| 169 | } | 176 | } |
| 170 | 177 | ||
| 171 | watched.unsubscribe(); | 178 | watched.unsubscribe(); |
| 172 | watchedGitStatuses.delete(cwd); | 179 | watchedGitStatuses.delete(targetKey); |
| 173 | } | 180 | } |
| 174 | 181 | ||
| 175 | function subscribeToGitStatus(cwd: string): () => void { | 182 | function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void { |
| 176 | const client = sharedGitStatusClient; | 183 | markGitStatusPending(targetKey); |
| 177 | if (!client) { | ||
| 178 | return NOOP; | ||
| 179 | } | ||
| 180 | |||
| 181 | markGitStatusPending(cwd); | ||
| 182 | return client.onStatus( | 184 | return client.onStatus( |
| 183 | { cwd }, | 185 | { cwd }, |
| 184 | (status) => { | 186 | (status) => { |
| 185 | appAtomRegistry.set(gitStatusStateAtom(cwd), { | 187 | appAtomRegistry.set(gitStatusStateAtom(targetKey), { |
| 186 | data: status, | 188 | data: status, |
| 187 | error: null, | 189 | error: null, |
| 188 | cause: null, | 190 | cause: null, |
| ... | @@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void { | ... | @@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void { |
| 191 | }, | 193 | }, |
| 192 | { | 194 | { |
| 193 | onResubscribe: () => { | 195 | onResubscribe: () => { |
| 194 | markGitStatusPending(cwd); | 196 | markGitStatusPending(targetKey); |
| 195 | }, | 197 | }, |
| 196 | }, | 198 | }, |
| 197 | ); | 199 | ); |
| 198 | } | 200 | } |
| 199 | 201 | ||
| 200 | function markGitStatusPending(cwd: string): void { | 202 | function markGitStatusPending(targetKey: string): void { |
| 201 | const atom = gitStatusStateAtom(cwd); | 203 | const atom = gitStatusStateAtom(targetKey); |
| 202 | const current = appAtomRegistry.get(atom); | 204 | const current = appAtomRegistry.get(atom); |
| 203 | const next = | 205 | const next = |
| 204 | current.data === null | 206 | current.data === null |
apps/web/src/lib/projectReactQuery.ts+17-8| ... | @@ -1,11 +1,15 @@ | ... | @@ -1,11 +1,15 @@ |
| 1 | import type { ProjectSearchEntriesResult } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; |
| 2 | import { queryOptions } from "@tanstack/react-query"; | 2 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | import { ensureNativeApi } from "~/nativeApi"; | 3 | import { ensureEnvironmentApi } from "~/environmentApi"; |
| 4 | 4 | ||
| 5 | export const projectQueryKeys = { | 5 | export const projectQueryKeys = { |
| 6 | all: ["projects"] as const, | 6 | all: ["projects"] as const, |
| 7 | searchEntries: (cwd: string | null, query: string, limit: number) => | 7 | searchEntries: ( |
| 8 | ["projects", "search-entries", cwd, query, limit] as const, | 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 | const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; | 15 | const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; |
| ... | @@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { | ... | @@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { |
| 16 | }; | 20 | }; |
| 17 | 21 | ||
| 18 | export function projectSearchEntriesQueryOptions(input: { | 22 | export function projectSearchEntriesQueryOptions(input: { |
| 23 | environmentId: EnvironmentId | null; | ||
| 19 | cwd: string | null; | 24 | cwd: string | null; |
| 20 | query: string; | 25 | query: string; |
| 21 | enabled?: boolean; | 26 | enabled?: boolean; |
| ... | @@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: { | ... | @@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: { |
| 24 | }) { | 29 | }) { |
| 25 | const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; | 30 | const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; |
| 26 | return queryOptions({ | 31 | return queryOptions({ |
| 27 | queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), | 32 | queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit), |
| 28 | queryFn: async () => { | 33 | queryFn: async () => { |
| 29 | const api = ensureNativeApi(); | 34 | if (!input.cwd || !input.environmentId) { |
| 30 | if (!input.cwd) { | ||
| 31 | throw new Error("Workspace entry search is unavailable."); | 35 | throw new Error("Workspace entry search is unavailable."); |
| 32 | } | 36 | } |
| 37 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 33 | return api.projects.searchEntries({ | 38 | return api.projects.searchEntries({ |
| 34 | cwd: input.cwd, | 39 | cwd: input.cwd, |
| 35 | query: input.query, | 40 | query: input.query, |
| 36 | limit, | 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 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, | 49 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, |
| 41 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, | 50 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, |
| 42 | }); | 51 | }); |
apps/web/src/lib/providerReactQuery.test.ts+11-4| ... | @@ -1,21 +1,22 @@ | ... | @@ -1,21 +1,22 @@ |
| 1 | import { ThreadId, type NativeApi } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts"; |
| 2 | import { QueryClient } from "@tanstack/react-query"; | 2 | import { QueryClient } from "@tanstack/react-query"; |
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | 3 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; | 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; |
| 5 | import * as nativeApi from "../nativeApi"; | 5 | import * as environmentApi from "../environmentApi"; |
| 6 | 6 | ||
| 7 | const threadId = ThreadId.makeUnsafe("thread-id"); | 7 | const threadId = ThreadId.makeUnsafe("thread-id"); |
| 8 | const environmentId = EnvironmentId.makeUnsafe("environment-local"); | ||
| 8 | 9 | ||
| 9 | function mockNativeApi(input: { | 10 | function mockNativeApi(input: { |
| 10 | getTurnDiff: ReturnType<typeof vi.fn>; | 11 | getTurnDiff: ReturnType<typeof vi.fn>; |
| 11 | getFullThreadDiff: ReturnType<typeof vi.fn>; | 12 | getFullThreadDiff: ReturnType<typeof vi.fn>; |
| 12 | }) { | 13 | }) { |
| 13 | vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ | 14 | vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({ |
| 14 | orchestration: { | 15 | orchestration: { |
| 15 | getTurnDiff: input.getTurnDiff, | 16 | getTurnDiff: input.getTurnDiff, |
| 16 | getFullThreadDiff: input.getFullThreadDiff, | 17 | getFullThreadDiff: input.getFullThreadDiff, |
| 17 | }, | 18 | }, |
| 18 | } as unknown as NativeApi); | 19 | } as unknown as EnvironmentApi); |
| 19 | } | 20 | } |
| 20 | 21 | ||
| 21 | afterEach(() => { | 22 | afterEach(() => { |
| ... | @@ -25,6 +26,7 @@ afterEach(() => { | ... | @@ -25,6 +26,7 @@ afterEach(() => { |
| 25 | describe("providerQueryKeys.checkpointDiff", () => { | 26 | describe("providerQueryKeys.checkpointDiff", () => { |
| 26 | it("includes cacheScope so reused turn counts do not collide", () => { | 27 | it("includes cacheScope so reused turn counts do not collide", () => { |
| 27 | const baseInput = { | 28 | const baseInput = { |
| 29 | environmentId, | ||
| 28 | threadId, | 30 | threadId, |
| 29 | fromTurnCount: 1, | 31 | fromTurnCount: 1, |
| 30 | toTurnCount: 2, | 32 | toTurnCount: 2, |
| ... | @@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 51 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | 53 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); |
| 52 | 54 | ||
| 53 | const options = checkpointDiffQueryOptions({ | 55 | const options = checkpointDiffQueryOptions({ |
| 56 | environmentId, | ||
| 54 | threadId, | 57 | threadId, |
| 55 | fromTurnCount: 3, | 58 | fromTurnCount: 3, |
| 56 | toTurnCount: 4, | 59 | toTurnCount: 4, |
| ... | @@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 74 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | 77 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); |
| 75 | 78 | ||
| 76 | const options = checkpointDiffQueryOptions({ | 79 | const options = checkpointDiffQueryOptions({ |
| 80 | environmentId, | ||
| 77 | threadId, | 81 | threadId, |
| 78 | fromTurnCount: 0, | 82 | fromTurnCount: 0, |
| 79 | toTurnCount: 2, | 83 | toTurnCount: 2, |
| ... | @@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 96 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | 100 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); |
| 97 | 101 | ||
| 98 | const options = checkpointDiffQueryOptions({ | 102 | const options = checkpointDiffQueryOptions({ |
| 103 | environmentId, | ||
| 99 | threadId, | 104 | threadId, |
| 100 | fromTurnCount: 4, | 105 | fromTurnCount: 4, |
| 101 | toTurnCount: 3, | 106 | toTurnCount: 3, |
| ... | @@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 113 | 118 | ||
| 114 | it("retries checkpoint-not-ready errors longer than generic failures", () => { | 119 | it("retries checkpoint-not-ready errors longer than generic failures", () => { |
| 115 | const options = checkpointDiffQueryOptions({ | 120 | const options = checkpointDiffQueryOptions({ |
| 121 | environmentId, | ||
| 116 | threadId, | 122 | threadId, |
| 117 | fromTurnCount: 1, | 123 | fromTurnCount: 1, |
| 118 | toTurnCount: 2, | 124 | toTurnCount: 2, |
| ... | @@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 137 | 143 | ||
| 138 | it("backs off longer for checkpoint-not-ready errors", () => { | 144 | it("backs off longer for checkpoint-not-ready errors", () => { |
| 139 | const options = checkpointDiffQueryOptions({ | 145 | const options = checkpointDiffQueryOptions({ |
| 146 | environmentId, | ||
| 140 | threadId, | 147 | threadId, |
| 141 | fromTurnCount: 1, | 148 | fromTurnCount: 1, |
| 142 | toTurnCount: 2, | 149 | toTurnCount: 2, |
apps/web/src/lib/providerReactQuery.ts+11-4| ... | @@ -1,13 +1,15 @@ | ... | @@ -1,13 +1,15 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | ||
| 2 | OrchestrationGetFullThreadDiffInput, | 3 | OrchestrationGetFullThreadDiffInput, |
| 3 | OrchestrationGetTurnDiffInput, | 4 | OrchestrationGetTurnDiffInput, |
| 4 | ThreadId, | 5 | ThreadId, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import { queryOptions } from "@tanstack/react-query"; | 7 | import { queryOptions } from "@tanstack/react-query"; |
| 7 | import { Option, Schema } from "effect"; | 8 | import { Option, Schema } from "effect"; |
| 8 | import { ensureNativeApi } from "../nativeApi"; | 9 | import { ensureEnvironmentApi } from "../environmentApi"; |
| 9 | 10 | ||
| 10 | interface CheckpointDiffQueryInput { | 11 | interface CheckpointDiffQueryInput { |
| 12 | environmentId: EnvironmentId | null; | ||
| 11 | threadId: ThreadId | null; | 13 | threadId: ThreadId | null; |
| 12 | fromTurnCount: number | null; | 14 | fromTurnCount: number | null; |
| 13 | toTurnCount: number | null; | 15 | toTurnCount: number | null; |
| ... | @@ -21,6 +23,7 @@ export const providerQueryKeys = { | ... | @@ -21,6 +23,7 @@ export const providerQueryKeys = { |
| 21 | [ | 23 | [ |
| 22 | "providers", | 24 | "providers", |
| 23 | "checkpointDiff", | 25 | "checkpointDiff", |
| 26 | input.environmentId ?? null, | ||
| 24 | input.threadId, | 27 | input.threadId, |
| 25 | input.fromTurnCount, | 28 | input.fromTurnCount, |
| 26 | input.toTurnCount, | 29 | input.toTurnCount, |
| ... | @@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { | ... | @@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { |
| 95 | return queryOptions({ | 98 | return queryOptions({ |
| 96 | queryKey: providerQueryKeys.checkpointDiff(input), | 99 | queryKey: providerQueryKeys.checkpointDiff(input), |
| 97 | queryFn: async () => { | 100 | queryFn: async () => { |
| 98 | const api = ensureNativeApi(); | 101 | if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") { |
| 99 | if (!input.threadId || decodedRequest._tag === "None") { | ||
| 100 | throw new Error("Checkpoint diff is unavailable."); | 102 | throw new Error("Checkpoint diff is unavailable."); |
| 101 | } | 103 | } |
| 104 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 102 | try { | 105 | try { |
| 103 | if (decodedRequest.value.kind === "fullThreadDiff") { | 106 | if (decodedRequest.value.kind === "fullThreadDiff") { |
| 104 | return await api.orchestration.getFullThreadDiff(decodedRequest.value.input); | 107 | return await api.orchestration.getFullThreadDiff(decodedRequest.value.input); |
| ... | @@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { | ... | @@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { |
| 108 | throw new Error(normalizeCheckpointErrorMessage(error), { cause: error }); | 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 | staleTime: Infinity, | 119 | staleTime: Infinity, |
| 113 | retry: (failureCount, error) => { | 120 | retry: (failureCount, error) => { |
| 114 | if (isCheckpointTemporarilyUnavailable(error)) { | 121 | if (isCheckpointTemporarilyUnavailable(error)) { |
apps/web/src/lib/terminalStateCleanup.test.ts+20-13| ... | @@ -1,58 +1,65 @@ | ... | @@ -1,58 +1,65 @@ |
| 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 3 | import { describe, expect, it } from "vitest"; |
| 3 | 4 | ||
| 4 | import { collectActiveTerminalThreadIds } from "./terminalStateCleanup"; | 5 | import { collectActiveTerminalThreadIds } from "./terminalStateCleanup"; |
| 5 | 6 | ||
| 6 | const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id); | 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 | describe("collectActiveTerminalThreadIds", () => { | 11 | describe("collectActiveTerminalThreadIds", () => { |
| 9 | it("retains non-deleted server threads", () => { | 12 | it("retains non-deleted server threads", () => { |
| 10 | const activeThreadIds = collectActiveTerminalThreadIds({ | 13 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 11 | snapshotThreads: [ | 14 | snapshotThreads: [ |
| 12 | { id: threadId("server-1"), deletedAt: null, archivedAt: null }, | 15 | { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null }, |
| 13 | { id: threadId("server-2"), 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 | it("ignores deleted and archived server threads and keeps local draft threads", () => { | 26 | it("ignores deleted and archived server threads and keeps local draft threads", () => { |
| 22 | const activeThreadIds = collectActiveTerminalThreadIds({ | 27 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 23 | snapshotThreads: [ | 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 | deletedAt: "2026-03-05T08:00:00.000Z", | 32 | deletedAt: "2026-03-05T08:00:00.000Z", |
| 28 | archivedAt: null, | 33 | archivedAt: null, |
| 29 | }, | 34 | }, |
| 30 | { | 35 | { |
| 31 | id: threadId("server-archived"), | 36 | key: threadKey("env-a", "server-archived"), |
| 32 | deletedAt: null, | 37 | deletedAt: null, |
| 33 | archivedAt: "2026-03-05T09:00:00.000Z", | 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 | it("does not keep draft-linked terminal state for archived server threads", () => { | 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 | const activeThreadIds = collectActiveTerminalThreadIds({ | 52 | const activeThreadIds = collectActiveTerminalThreadIds({ |
| 46 | snapshotThreads: [ | 53 | snapshotThreads: [ |
| 47 | { | 54 | { |
| 48 | id: archivedThreadId, | 55 | key: archivedThreadId, |
| 49 | deletedAt: null, | 56 | deletedAt: null, |
| 50 | archivedAt: "2026-03-05T09:00:00.000Z", | 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,35 +1,33 @@ |
| 1 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | interface TerminalRetentionThread { | 1 | interface TerminalRetentionThread { |
| 4 | id: ThreadId; | 2 | key: string; |
| 5 | deletedAt: string | null; | 3 | deletedAt: string | null; |
| 6 | archivedAt: string | null; | 4 | archivedAt: string | null; |
| 7 | } | 5 | } |
| 8 | 6 | ||
| 9 | interface CollectActiveTerminalThreadIdsInput { | 7 | interface CollectActiveTerminalThreadIdsInput { |
| 10 | snapshotThreads: readonly TerminalRetentionThread[]; | 8 | snapshotThreads: readonly TerminalRetentionThread[]; |
| 11 | draftThreadIds: Iterable<ThreadId>; | 9 | draftThreadKeys: Iterable<string>; |
| 12 | } | 10 | } |
| 13 | 11 | ||
| 14 | export function collectActiveTerminalThreadIds( | 12 | export function collectActiveTerminalThreadIds( |
| 15 | input: CollectActiveTerminalThreadIdsInput, | 13 | input: CollectActiveTerminalThreadIdsInput, |
| 16 | ): Set<ThreadId> { | 14 | ): Set<string> { |
| 17 | const activeThreadIds = new Set<ThreadId>(); | 15 | const activeThreadIds = new Set<string>(); |
| 18 | const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread])); | 16 | const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread])); |
| 19 | for (const thread of input.snapshotThreads) { | 17 | for (const thread of input.snapshotThreads) { |
| 20 | if (thread.deletedAt !== null) continue; | 18 | if (thread.deletedAt !== null) continue; |
| 21 | if (thread.archivedAt !== null) continue; | 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) { | 22 | for (const draftThreadKey of input.draftThreadKeys) { |
| 25 | const snapshotThread = snapshotThreadById.get(draftThreadId); | 23 | const snapshotThread = snapshotThreadById.get(draftThreadKey); |
| 26 | if ( | 24 | if ( |
| 27 | snapshotThread && | 25 | snapshotThread && |
| 28 | (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) | 26 | (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) |
| 29 | ) { | 27 | ) { |
| 30 | continue; | 28 | continue; |
| 31 | } | 29 | } |
| 32 | activeThreadIds.add(draftThreadId); | 30 | activeThreadIds.add(draftThreadKey); |
| 33 | } | 31 | } |
| 34 | return activeThreadIds; | 32 | return activeThreadIds; |
| 35 | } | 33 | } |
apps/web/src/lib/utils.test.ts+41-1| ... | @@ -1,6 +1,15 @@ | ... | @@ -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 | import { isWindowsPlatform } from "./utils"; | 11 | import { isWindowsPlatform } from "./utils"; |
| 12 | import { resolveServerUrl } from "./utils"; | ||
| 4 | 13 | ||
| 5 | describe("isWindowsPlatform", () => { | 14 | describe("isWindowsPlatform", () => { |
| 6 | it("matches Windows platform identifiers", () => { | 15 | it("matches Windows platform identifiers", () => { |
| ... | @@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => { | ... | @@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => { |
| 13 | assert.isFalse(isWindowsPlatform("darwin")); | 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,6 +4,8 @@ import { type CxOptions, cx } from "class-variance-authority"; |
| 4 | import { twMerge } from "tailwind-merge"; | 4 | import { twMerge } from "tailwind-merge"; |
| 5 | import * as Random from "effect/Random"; | 5 | import * as Random from "effect/Random"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap"; | ||
| 8 | import { DraftId } from "../composerDraftStore"; | ||
| 7 | 9 | ||
| 8 | export function cn(...inputs: CxOptions) { | 10 | export function cn(...inputs: CxOptions) { |
| 9 | return twMerge(cx(inputs)); | 11 | return twMerge(cx(inputs)); |
| ... | @@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); | ... | @@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); |
| 34 | 36 | ||
| 35 | export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); | 37 | export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); |
| 36 | 38 | ||
| 39 | export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID()); | ||
| 40 | |||
| 37 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); | 41 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); |
| 38 | 42 | ||
| 39 | const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); | 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 | export const resolveServerUrl = (options?: { | 45 | export const resolveServerUrl = (options?: { |
| 50 | url?: string | undefined; | 46 | url?: string | undefined; |
| ... | @@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: { | ... | @@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: { |
| 52 | pathname?: string | undefined; | 48 | pathname?: string | undefined; |
| 53 | searchParams?: Record<string, string> | undefined; | 49 | searchParams?: Record<string, string> | undefined; |
| 54 | }): string => { | 50 | }): string => { |
| 55 | const rawUrl = firstNonEmptyString( | 51 | const rawUrl = isNonEmptyString(options?.url) |
| 56 | options?.url, | 52 | ? options.url |
| 57 | window.desktopBridge?.getWsUrl(), | 53 | : resolvePrimaryEnvironmentBootstrapUrl(); |
| 58 | import.meta.env.VITE_WS_URL, | ||
| 59 | window.location.origin, | ||
| 60 | ); | ||
| 61 | 54 | ||
| 62 | const parsedUrl = new URL(rawUrl); | 55 | const parsedUrl = new URL(rawUrl); |
| 63 | if (options?.protocol) { | 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,7 +68,7 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 68 | }), | 68 | }), |
| 69 | ]); | 69 | ]); |
| 70 | 70 | ||
| 71 | expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); | 71 | expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]); |
| 72 | expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); | 72 | expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); |
| 73 | expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); | 73 | expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); |
| 74 | expect(effects.needsProviderInvalidation).toBe(false); | 74 | expect(effects.needsProviderInvalidation).toBe(false); |
| ... | @@ -106,7 +106,7 @@ describe("deriveOrchestrationBatchEffects", () => { | ... | @@ -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 | expect(effects.clearDeletedThreadIds).toEqual([]); | 110 | expect(effects.clearDeletedThreadIds).toEqual([]); |
| 111 | expect(effects.removeTerminalStateThreadIds).toEqual([]); | 111 | expect(effects.removeTerminalStateThreadIds).toEqual([]); |
| 112 | expect(effects.needsProviderInvalidation).toBe(true); | 112 | expect(effects.needsProviderInvalidation).toBe(true); |
| ... | @@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => { | ... | @@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 127 | }), | 127 | }), |
| 128 | ]); | 128 | ]); |
| 129 | 129 | ||
| 130 | expect(effects.clearPromotedDraftThreadIds).toEqual([]); | 130 | expect(effects.promoteDraftThreadIds).toEqual([]); |
| 131 | expect(effects.clearDeletedThreadIds).toEqual([]); | 131 | expect(effects.clearDeletedThreadIds).toEqual([]); |
| 132 | expect(effects.removeTerminalStateThreadIds).toEqual([]); | 132 | expect(effects.removeTerminalStateThreadIds).toEqual([]); |
| 133 | }); | 133 | }); |
apps/web/src/orchestrationEventEffects.ts+4-4| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; | 1 | import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | export interface OrchestrationBatchEffects { | 3 | export interface OrchestrationBatchEffects { |
| 4 | clearPromotedDraftThreadIds: ThreadId[]; | 4 | promoteDraftThreadIds: ThreadId[]; |
| 5 | clearDeletedThreadIds: ThreadId[]; | 5 | clearDeletedThreadIds: ThreadId[]; |
| 6 | removeTerminalStateThreadIds: ThreadId[]; | 6 | removeTerminalStateThreadIds: ThreadId[]; |
| 7 | needsProviderInvalidation: boolean; | 7 | needsProviderInvalidation: boolean; |
| ... | @@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects( |
| 70 | } | 70 | } |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | const clearPromotedDraftThreadIds: ThreadId[] = []; | 73 | const promoteDraftThreadIds: ThreadId[] = []; |
| 74 | const clearDeletedThreadIds: ThreadId[] = []; | 74 | const clearDeletedThreadIds: ThreadId[] = []; |
| 75 | const removeTerminalStateThreadIds: ThreadId[] = []; | 75 | const removeTerminalStateThreadIds: ThreadId[] = []; |
| 76 | for (const [threadId, effect] of threadLifecycleEffects) { | 76 | for (const [threadId, effect] of threadLifecycleEffects) { |
| 77 | if (effect.clearPromotedDraft) { | 77 | if (effect.clearPromotedDraft) { |
| 78 | clearPromotedDraftThreadIds.push(threadId); | 78 | promoteDraftThreadIds.push(threadId); |
| 79 | } | 79 | } |
| 80 | if (effect.clearDeletedThread) { | 80 | if (effect.clearDeletedThread) { |
| 81 | clearDeletedThreadIds.push(threadId); | 81 | clearDeletedThreadIds.push(threadId); |
| ... | @@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects( |
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | return { | 88 | return { |
| 89 | clearPromotedDraftThreadIds, | 89 | promoteDraftThreadIds, |
| 90 | clearDeletedThreadIds, | 90 | clearDeletedThreadIds, |
| 91 | removeTerminalStateThreadIds, | 91 | removeTerminalStateThreadIds, |
| 92 | needsProviderInvalidation, | 92 | needsProviderInvalidation, |
apps/web/src/routeTree.gen.ts+39-17| ... | @@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' | ... | @@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' |
| 14 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 14 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 15 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 15 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 16 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 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 | const SettingsRoute = SettingsRouteImport.update({ | 20 | const SettingsRoute = SettingsRouteImport.update({ |
| 20 | id: '/settings', | 21 | id: '/settings', |
| ... | @@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ | ... | @@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ |
| 40 | path: '/archived', | 41 | path: '/archived', |
| 41 | getParentRoute: () => SettingsRoute, | 42 | getParentRoute: () => SettingsRoute, |
| 42 | } as any) | 43 | } as any) |
| 43 | const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ | 44 | const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ |
| 44 | id: '/$threadId', | 45 | id: '/draft/$draftId', |
| 45 | path: '/$threadId', | 46 | path: '/draft/$draftId', |
| 46 | getParentRoute: () => ChatRoute, | 47 | getParentRoute: () => ChatRoute, |
| 47 | } as any) | 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 | export interface FileRoutesByFullPath { | 56 | export interface FileRoutesByFullPath { |
| 50 | '/': typeof ChatIndexRoute | 57 | '/': typeof ChatIndexRoute |
| 51 | '/settings': typeof SettingsRouteWithChildren | 58 | '/settings': typeof SettingsRouteWithChildren |
| 52 | '/$threadId': typeof ChatThreadIdRoute | ||
| 53 | '/settings/archived': typeof SettingsArchivedRoute | 59 | '/settings/archived': typeof SettingsArchivedRoute |
| 54 | '/settings/general': typeof SettingsGeneralRoute | 60 | '/settings/general': typeof SettingsGeneralRoute |
| 61 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | ||
| 62 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | ||
| 55 | } | 63 | } |
| 56 | export interface FileRoutesByTo { | 64 | export interface FileRoutesByTo { |
| 57 | '/settings': typeof SettingsRouteWithChildren | 65 | '/settings': typeof SettingsRouteWithChildren |
| 58 | '/$threadId': typeof ChatThreadIdRoute | ||
| 59 | '/settings/archived': typeof SettingsArchivedRoute | 66 | '/settings/archived': typeof SettingsArchivedRoute |
| 60 | '/settings/general': typeof SettingsGeneralRoute | 67 | '/settings/general': typeof SettingsGeneralRoute |
| 61 | '/': typeof ChatIndexRoute | 68 | '/': typeof ChatIndexRoute |
| 69 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | ||
| 70 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | ||
| 62 | } | 71 | } |
| 63 | export interface FileRoutesById { | 72 | export interface FileRoutesById { |
| 64 | __root__: typeof rootRouteImport | 73 | __root__: typeof rootRouteImport |
| 65 | '/_chat': typeof ChatRouteWithChildren | 74 | '/_chat': typeof ChatRouteWithChildren |
| 66 | '/settings': typeof SettingsRouteWithChildren | 75 | '/settings': typeof SettingsRouteWithChildren |
| 67 | '/_chat/$threadId': typeof ChatThreadIdRoute | ||
| 68 | '/settings/archived': typeof SettingsArchivedRoute | 76 | '/settings/archived': typeof SettingsArchivedRoute |
| 69 | '/settings/general': typeof SettingsGeneralRoute | 77 | '/settings/general': typeof SettingsGeneralRoute |
| 70 | '/_chat/': typeof ChatIndexRoute | 78 | '/_chat/': typeof ChatIndexRoute |
| 79 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | ||
| 80 | '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute | ||
| 71 | } | 81 | } |
| 72 | export interface FileRouteTypes { | 82 | export interface FileRouteTypes { |
| 73 | fileRoutesByFullPath: FileRoutesByFullPath | 83 | fileRoutesByFullPath: FileRoutesByFullPath |
| 74 | fullPaths: | 84 | fullPaths: |
| 75 | | '/' | 85 | | '/' |
| 76 | | '/settings' | 86 | | '/settings' |
| 77 | | '/$threadId' | ||
| 78 | | '/settings/archived' | 87 | | '/settings/archived' |
| 79 | | '/settings/general' | 88 | | '/settings/general' |
| 89 | | '/$environmentId/$threadId' | ||
| 90 | | '/draft/$draftId' | ||
| 80 | fileRoutesByTo: FileRoutesByTo | 91 | fileRoutesByTo: FileRoutesByTo |
| 81 | to: | 92 | to: |
| 82 | | '/settings' | 93 | | '/settings' |
| 83 | | '/$threadId' | ||
| 84 | | '/settings/archived' | 94 | | '/settings/archived' |
| 85 | | '/settings/general' | 95 | | '/settings/general' |
| 86 | | '/' | 96 | | '/' |
| 97 | | '/$environmentId/$threadId' | ||
| 98 | | '/draft/$draftId' | ||
| 87 | id: | 99 | id: |
| 88 | | '__root__' | 100 | | '__root__' |
| 89 | | '/_chat' | 101 | | '/_chat' |
| 90 | | '/settings' | 102 | | '/settings' |
| 91 | | '/_chat/$threadId' | ||
| 92 | | '/settings/archived' | 103 | | '/settings/archived' |
| 93 | | '/settings/general' | 104 | | '/settings/general' |
| 94 | | '/_chat/' | 105 | | '/_chat/' |
| 106 | | '/_chat/$environmentId/$threadId' | ||
| 107 | | '/_chat/draft/$draftId' | ||
| 95 | fileRoutesById: FileRoutesById | 108 | fileRoutesById: FileRoutesById |
| 96 | } | 109 | } |
| 97 | export interface RootRouteChildren { | 110 | export interface RootRouteChildren { |
| ... | @@ -136,24 +149,33 @@ declare module '@tanstack/react-router' { | ... | @@ -136,24 +149,33 @@ declare module '@tanstack/react-router' { |
| 136 | preLoaderRoute: typeof SettingsArchivedRouteImport | 149 | preLoaderRoute: typeof SettingsArchivedRouteImport |
| 137 | parentRoute: typeof SettingsRoute | 150 | parentRoute: typeof SettingsRoute |
| 138 | } | 151 | } |
| 139 | '/_chat/$threadId': { | 152 | '/_chat/draft/$draftId': { |
| 140 | id: '/_chat/$threadId' | 153 | id: '/_chat/draft/$draftId' |
| 141 | path: '/$threadId' | 154 | path: '/draft/$draftId' |
| 142 | fullPath: '/$threadId' | 155 | fullPath: '/draft/$draftId' |
| 143 | preLoaderRoute: typeof ChatThreadIdRouteImport | 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 | parentRoute: typeof ChatRoute | 164 | parentRoute: typeof ChatRoute |
| 145 | } | 165 | } |
| 146 | } | 166 | } |
| 147 | } | 167 | } |
| 148 | 168 | ||
| 149 | interface ChatRouteChildren { | 169 | interface ChatRouteChildren { |
| 150 | ChatThreadIdRoute: typeof ChatThreadIdRoute | ||
| 151 | ChatIndexRoute: typeof ChatIndexRoute | 170 | ChatIndexRoute: typeof ChatIndexRoute |
| 171 | ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute | ||
| 172 | ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute | ||
| 152 | } | 173 | } |
| 153 | 174 | ||
| 154 | const ChatRouteChildren: ChatRouteChildren = { | 175 | const ChatRouteChildren: ChatRouteChildren = { |
| 155 | ChatThreadIdRoute: ChatThreadIdRoute, | ||
| 156 | ChatIndexRoute: ChatIndexRoute, | 176 | ChatIndexRoute: ChatIndexRoute, |
| 177 | ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, | ||
| 178 | ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, | ||
| 157 | } | 179 | } |
| 158 | 180 | ||
| 159 | const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) | 181 | const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) |
apps/web/src/routes/__root.tsx+319-166| ... | @@ -1,8 +1,15 @@ | ... | @@ -1,8 +1,15 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | ||
| 2 | OrchestrationEvent, | 3 | OrchestrationEvent, |
| 3 | type ServerLifecycleWelcomePayload, | 4 | type ServerLifecycleWelcomePayload, |
| 4 | ThreadId, | 5 | ThreadId, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { | ||
| 8 | scopedProjectKey, | ||
| 9 | scopedThreadKey, | ||
| 10 | scopeProjectRef, | ||
| 11 | scopeThreadRef, | ||
| 12 | } from "@t3tools/client-runtime"; | ||
| 6 | import { | 13 | import { |
| 7 | Outlet, | 14 | Outlet, |
| 8 | createRootRouteWithContext, | 15 | createRootRouteWithContext, |
| ... | @@ -10,7 +17,7 @@ import { | ... | @@ -10,7 +17,7 @@ import { |
| 10 | useNavigate, | 17 | useNavigate, |
| 11 | useLocation, | 18 | useLocation, |
| 12 | } from "@tanstack/react-router"; | 19 | } from "@tanstack/react-router"; |
| 13 | import { useEffect, useEffectEvent, useRef } from "react"; | 20 | import { useEffect, useEffectEvent, useRef, useState } from "react"; |
| 14 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; | 21 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 15 | import { Throttler } from "@tanstack/react-pacer"; | 22 | import { Throttler } from "@tanstack/react-pacer"; |
| 16 | 23 | ||
| ... | @@ -24,7 +31,7 @@ import { | ... | @@ -24,7 +31,7 @@ import { |
| 24 | import { Button } from "../components/ui/button"; | 31 | import { Button } from "../components/ui/button"; |
| 25 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; | 32 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; |
| 26 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | 33 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 27 | import { readNativeApi } from "../nativeApi"; | 34 | import { readLocalApi } from "../localApi"; |
| 28 | import { | 35 | import { |
| 29 | getServerConfigUpdatedNotification, | 36 | getServerConfigUpdatedNotification, |
| 30 | ServerConfigUpdatedNotification, | 37 | ServerConfigUpdatedNotification, |
| ... | @@ -34,11 +41,15 @@ import { | ... | @@ -34,11 +41,15 @@ import { |
| 34 | useServerWelcomeSubscription, | 41 | useServerWelcomeSubscription, |
| 35 | } from "../rpc/serverState"; | 42 | } from "../rpc/serverState"; |
| 36 | import { | 43 | import { |
| 37 | clearPromotedDraftThread, | 44 | markPromotedDraftThreadByRef, |
| 38 | clearPromotedDraftThreads, | 45 | markPromotedDraftThreadsByRef, |
| 39 | useComposerDraftStore, | 46 | useComposerDraftStore, |
| 40 | } from "../composerDraftStore"; | 47 | } from "../composerDraftStore"; |
| 41 | import { selectProjects, selectThreadById, selectThreads, useStore } from "../store"; | 48 | import { |
| 49 | selectProjectsAcrossEnvironments, | ||
| 50 | selectThreadsAcrossEnvironments, | ||
| 51 | useStore, | ||
| 52 | } from "../store"; | ||
| 42 | import { useUiStateStore } from "../uiStateStore"; | 53 | import { useUiStateStore } from "../uiStateStore"; |
| 43 | import { useTerminalStateStore } from "../terminalStateStore"; | 54 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 44 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; | 55 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; |
| ... | @@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; | ... | @@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; |
| 48 | import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; | 59 | import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; |
| 49 | import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; | 60 | import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; |
| 50 | import { deriveReplayRetryDecision } from "../orchestrationRecovery"; | 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 | export const Route = createRootRouteWithContext<{ | 72 | export const Route = createRootRouteWithContext<{ |
| 54 | queryClient: QueryClient; | 73 | queryClient: QueryClient; |
| ... | @@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{ | ... | @@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{ |
| 61 | }); | 80 | }); |
| 62 | 81 | ||
| 63 | function RootRouteView() { | 82 | function RootRouteView() { |
| 64 | if (!readNativeApi()) { | 83 | if (!readLocalApi()) { |
| 65 | return ( | 84 | return ( |
| 66 | <div className="flex h-screen flex-col bg-background text-foreground"> | 85 | <div className="flex h-screen flex-col bg-background text-foreground"> |
| 67 | <div className="flex flex-1 items-center justify-center"> | 86 | <div className="flex flex-1 items-center justify-center"> |
| ... | @@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents( | ... | @@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents( |
| 201 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; | 220 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; |
| 202 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; | 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 | function ServerStateBootstrap() { | 232 | function ServerStateBootstrap() { |
| 205 | useEffect(() => startServerStateSync(getWsRpcClient().server), []); | 233 | useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []); |
| 206 | 234 | ||
| 207 | return null; | 235 | return null; |
| 208 | } | 236 | } |
| 209 | 237 | ||
| 210 | function EventRouter() { | 238 | function EventRouter() { |
| 211 | const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); | 239 | const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); |
| 240 | const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); | ||
| 212 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); | 241 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); |
| 213 | const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); | 242 | const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); |
| 214 | const syncProjects = useUiStateStore((store) => store.syncProjects); | 243 | const syncProjects = useUiStateStore((store) => store.syncProjects); |
| ... | @@ -226,15 +255,22 @@ function EventRouter() { | ... | @@ -226,15 +255,22 @@ function EventRouter() { |
| 226 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | 255 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 227 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); | 256 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); |
| 228 | const disposedRef = useRef(false); | 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 | const serverConfig = useServerConfig(); | 262 | const serverConfig = useServerConfig(); |
| 263 | const clientEntries = useRegisteredWsRpcClientEntries(); | ||
| 231 | 264 | ||
| 232 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { | 265 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { |
| 233 | if (!payload) return; | 266 | if (!payload) return; |
| 234 | 267 | ||
| 268 | bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId); | ||
| 269 | setActiveEnvironmentId(payload.environment.environmentId); | ||
| 270 | schedulePendingDomainEventFlushRef.current(); | ||
| 235 | migrateLocalSettingsToServer(); | 271 | migrateLocalSettingsToServer(); |
| 236 | void (async () => { | 272 | void (async () => { |
| 237 | await bootstrapFromSnapshotRef.current(); | 273 | await bootstrapFromSnapshotRef.current(payload.environment.environmentId); |
| 238 | if (disposedRef.current) { | 274 | if (disposedRef.current) { |
| 239 | return; | 275 | return; |
| 240 | } | 276 | } |
| ... | @@ -242,7 +278,12 @@ function EventRouter() { | ... | @@ -242,7 +278,12 @@ function EventRouter() { |
| 242 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { | 278 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { |
| 243 | return; | 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 | if (readPathname() !== "/") { | 288 | if (readPathname() !== "/") { |
| 248 | return; | 289 | return; |
| ... | @@ -251,8 +292,11 @@ function EventRouter() { | ... | @@ -251,8 +292,11 @@ function EventRouter() { |
| 251 | return; | 292 | return; |
| 252 | } | 293 | } |
| 253 | await navigate({ | 294 | await navigate({ |
| 254 | to: "/$threadId", | 295 | to: "/$environmentId/$threadId", |
| 255 | params: { threadId: payload.bootstrapThreadId }, | 296 | params: { |
| 297 | environmentId: payload.environment.environmentId, | ||
| 298 | threadId: payload.bootstrapThreadId, | ||
| 299 | }, | ||
| 256 | replace: true, | 300 | replace: true, |
| 257 | }); | 301 | }); |
| 258 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; | 302 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; |
| ... | @@ -289,7 +333,7 @@ function EventRouter() { | ... | @@ -289,7 +333,7 @@ function EventRouter() { |
| 289 | actionProps: { | 333 | actionProps: { |
| 290 | children: "Open keybindings.json", | 334 | children: "Open keybindings.json", |
| 291 | onClick: () => { | 335 | onClick: () => { |
| 292 | const api = readNativeApi(); | 336 | const api = readLocalApi(); |
| 293 | if (!api) { | 337 | if (!api) { |
| 294 | return; | 338 | return; |
| 295 | } | 339 | } |
| ... | @@ -317,40 +361,49 @@ function EventRouter() { | ... | @@ -317,40 +361,49 @@ function EventRouter() { |
| 317 | ); | 361 | ); |
| 318 | 362 | ||
| 319 | useEffect(() => { | 363 | useEffect(() => { |
| 320 | const api = readNativeApi(); | 364 | if (!serverConfig) { |
| 321 | if (!api) return; | 365 | return; |
| 366 | } | ||
| 367 | |||
| 368 | bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId); | ||
| 369 | setActiveEnvironmentId(serverConfig.environment.environmentId); | ||
| 370 | schedulePendingDomainEventFlushRef.current(); | ||
| 371 | }, [serverConfig, setActiveEnvironmentId]); | ||
| 372 | |||
| 373 | useEffect(() => { | ||
| 322 | let disposed = false; | 374 | let disposed = false; |
| 323 | disposedRef.current = false; | 375 | disposedRef.current = false; |
| 324 | const recovery = createOrchestrationRecoveryCoordinator(); | ||
| 325 | let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; | ||
| 326 | let needsProviderInvalidation = false; | 376 | let needsProviderInvalidation = false; |
| 327 | const pendingDomainEvents: OrchestrationEvent[] = []; | 377 | const primaryClientKey = getPrimaryWsRpcClientEntry().key; |
| 328 | let flushPendingDomainEventsScheduled = false; | ||
| 329 | 378 | ||
| 330 | const reconcileSnapshotDerivedState = () => { | 379 | const reconcileSnapshotDerivedState = () => { |
| 331 | const storeState = useStore.getState(); | 380 | const storeState = useStore.getState(); |
| 332 | const threads = selectThreads(storeState); | 381 | const threads = selectThreadsAcrossEnvironments(storeState); |
| 333 | const projects = selectProjects(storeState); | 382 | const projects = selectProjectsAcrossEnvironments(storeState); |
| 334 | syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); | 383 | syncProjects( |
| 384 | projects.map((project) => ({ | ||
| 385 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | ||
| 386 | cwd: project.cwd, | ||
| 387 | })), | ||
| 388 | ); | ||
| 335 | syncThreads( | 389 | syncThreads( |
| 336 | threads.map((thread) => ({ | 390 | threads.map((thread) => ({ |
| 337 | id: thread.id, | 391 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 338 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | 392 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, |
| 339 | })), | 393 | })), |
| 340 | ); | 394 | ); |
| 341 | clearPromotedDraftThreads(threads.map((thread) => thread.id)); | 395 | markPromotedDraftThreadsByRef( |
| 342 | const draftThreadIds = Object.keys( | 396 | threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), |
| 343 | useComposerDraftStore.getState().draftThreadsByThreadId, | 397 | ); |
| 344 | ) as ThreadId[]; | 398 | const activeThreadKeys = collectActiveTerminalThreadIds({ |
| 345 | const activeThreadIds = collectActiveTerminalThreadIds({ | ||
| 346 | snapshotThreads: threads.map((thread) => ({ | 399 | snapshotThreads: threads.map((thread) => ({ |
| 347 | id: thread.id, | 400 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 348 | deletedAt: null, | 401 | deletedAt: null, |
| 349 | archivedAt: thread.archivedAt, | 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 | const queryInvalidationThrottler = new Throttler( | 409 | const queryInvalidationThrottler = new Throttler( |
| ... | @@ -371,7 +424,11 @@ function EventRouter() { | ... | @@ -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 | const nextEvents = recovery.markEventBatchApplied(events); | 432 | const nextEvents = recovery.markEventBatchApplied(events); |
| 376 | if (nextEvents.length === 0) { | 433 | if (nextEvents.length === 0) { |
| 377 | return; | 434 | return; |
| ... | @@ -391,189 +448,284 @@ function EventRouter() { | ... | @@ -391,189 +448,284 @@ function EventRouter() { |
| 391 | void queryInvalidationThrottler.maybeExecute(); | 448 | void queryInvalidationThrottler.maybeExecute(); |
| 392 | } | 449 | } |
| 393 | 450 | ||
| 394 | applyOrchestrationEvents(uiEvents); | 451 | applyOrchestrationEvents(uiEvents, environmentId); |
| 395 | if (needsProjectUiSync) { | 452 | if (needsProjectUiSync) { |
| 396 | const projects = selectProjects(useStore.getState()); | 453 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); |
| 397 | syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); | 454 | syncProjects( |
| 455 | projects.map((project) => ({ | ||
| 456 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | ||
| 457 | cwd: project.cwd, | ||
| 458 | })), | ||
| 459 | ); | ||
| 398 | } | 460 | } |
| 399 | const needsThreadUiSync = nextEvents.some( | 461 | const needsThreadUiSync = nextEvents.some( |
| 400 | (event) => event.type === "thread.created" || event.type === "thread.deleted", | 462 | (event) => event.type === "thread.created" || event.type === "thread.deleted", |
| 401 | ); | 463 | ); |
| 402 | if (needsThreadUiSync) { | 464 | if (needsThreadUiSync) { |
| 403 | const threads = selectThreads(useStore.getState()); | 465 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); |
| 404 | syncThreads( | 466 | syncThreads( |
| 405 | threads.map((thread) => ({ | 467 | threads.map((thread) => ({ |
| 406 | id: thread.id, | 468 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 407 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | 469 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, |
| 408 | })), | 470 | })), |
| 409 | ); | 471 | ); |
| 410 | } | 472 | } |
| 411 | const draftStore = useComposerDraftStore.getState(); | 473 | const draftStore = useComposerDraftStore.getState(); |
| 412 | for (const threadId of batchEffects.clearPromotedDraftThreadIds) { | 474 | for (const threadId of batchEffects.promoteDraftThreadIds) { |
| 413 | clearPromotedDraftThread(threadId); | 475 | markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); |
| 414 | } | 476 | } |
| 415 | for (const threadId of batchEffects.clearDeletedThreadIds) { | 477 | for (const threadId of batchEffects.clearDeletedThreadIds) { |
| 416 | draftStore.clearDraftThread(threadId); | 478 | draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); |
| 417 | clearThreadUi(threadId); | 479 | clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); |
| 418 | } | 480 | } |
| 419 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { | 481 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { |
| 420 | removeTerminalState(threadId); | 482 | removeTerminalState(scopeThreadRef(environmentId, threadId)); |
| 421 | } | 483 | } |
| 422 | }; | 484 | }; |
| 423 | const flushPendingDomainEvents = () => { | 485 | const clientContexts = clientEntries.map((entry) => { |
| 424 | flushPendingDomainEventsScheduled = false; | 486 | const recovery = createOrchestrationRecoveryCoordinator(); |
| 425 | if (disposed || pendingDomainEvents.length === 0) { | 487 | let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; |
| 426 | return; | 488 | const pendingDomainEvents: OrchestrationEvent[] = []; |
| 427 | } | 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); | 501 | const flushPendingDomainEvents = () => { |
| 430 | applyEventBatch(events); | 502 | flushPendingDomainEventsScheduled = false; |
| 431 | }; | 503 | if (disposed || pendingDomainEvents.length === 0 || boundEnvironmentId === null) { |
| 432 | const schedulePendingDomainEventFlush = () => { | 504 | return; |
| 433 | if (flushPendingDomainEventsScheduled) { | 505 | } |
| 434 | return; | ||
| 435 | } | ||
| 436 | 506 | ||
| 437 | flushPendingDomainEventsScheduled = true; | 507 | const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); |
| 438 | queueMicrotask(flushPendingDomainEvents); | 508 | applyEventBatch(events, boundEnvironmentId, recovery); |
| 439 | }; | 509 | }; |
| 440 | 510 | ||
| 441 | const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => { | 511 | const schedulePendingDomainEventFlush = () => { |
| 442 | if (!recovery.beginReplayRecovery(reason)) { | 512 | if (flushPendingDomainEventsScheduled) { |
| 443 | return; | 513 | return; |
| 444 | } | 514 | } |
| 445 | 515 | ||
| 446 | const fromSequenceExclusive = recovery.getState().latestSequence; | 516 | flushPendingDomainEventsScheduled = true; |
| 447 | try { | 517 | queueMicrotask(flushPendingDomainEvents); |
| 448 | const events = await api.orchestration.replayEvents(fromSequenceExclusive); | 518 | }; |
| 449 | if (!disposed) { | 519 | |
| 450 | applyEventBatch(events); | 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) { | 545 | try { |
| 460 | const replayCompletion = recovery.completeReplayRecovery(); | 546 | const snapshot = await entry.client.orchestration.getSnapshot(); |
| 461 | const retryDecision = deriveReplayRetryDecision({ | 547 | if (!disposed) { |
| 462 | previousTracker: replayRetryTracker, | 548 | bindEnvironmentId(environmentId); |
| 463 | completion: replayCompletion, | 549 | syncServerReadModel(snapshot, environmentId); |
| 464 | recoveryState: recovery.getState(), | 550 | reconcileSnapshotDerivedState(); |
| 465 | baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, | 551 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { |
| 466 | maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, | 552 | void runReplayRecovery("sequence-gap"); |
| 467 | }); | 553 | } |
| 468 | replayRetryTracker = retryDecision.tracker; | 554 | } |
| 555 | } catch { | ||
| 556 | recovery.failSnapshotRecovery(); | ||
| 557 | } | ||
| 558 | }; | ||
| 469 | 559 | ||
| 470 | if (retryDecision.shouldRetry) { | 560 | const fallbackToSnapshotRecovery = async (): Promise<void> => { |
| 471 | if (retryDecision.delayMs > 0) { | 561 | if (boundEnvironmentId === null) { |
| 472 | await new Promise<void>((resolve) => { | 562 | return; |
| 473 | setTimeout(resolve, retryDecision.delayMs); | 563 | } |
| 474 | }); | 564 | await runSnapshotRecovery("replay-failed", boundEnvironmentId); |
| 475 | if (disposed) { | 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 | return; | 579 | return; |
| 477 | } | 580 | } |
| 581 | applyEventBatch(events, boundEnvironmentId, recovery); | ||
| 478 | } | 582 | } |
| 479 | void runReplayRecovery(reason); | 583 | } catch { |
| 480 | } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { | 584 | replayRetryTracker = null; |
| 481 | console.warn( | 585 | recovery.failReplayRecovery(); |
| 482 | "[orchestration-recovery]", | 586 | void fallbackToSnapshotRecovery(); |
| 483 | "Stopping replay recovery after no-progress retries.", | 587 | return; |
| 484 | { | ||
| 485 | state: recovery.getState(), | ||
| 486 | }, | ||
| 487 | ); | ||
| 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 | if (!disposed) { | 590 | if (!disposed) { |
| 515 | syncServerReadModel(snapshot); | 591 | const replayCompletion = recovery.completeReplayRecovery(); |
| 516 | reconcileSnapshotDerivedState(); | 592 | const retryDecision = deriveReplayRetryDecision({ |
| 517 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { | 593 | previousTracker: replayRetryTracker, |
| 518 | void runReplayRecovery("sequence-gap"); | 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 { | 623 | }; |
| 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; | ||
| 531 | 624 | ||
| 532 | const fallbackToSnapshotRecovery = async (): Promise<void> => { | 625 | const unsubLifecycle = entry.client.server.subscribeLifecycle((event) => { |
| 533 | await runSnapshotRecovery("replay-failed"); | 626 | if (event.type === "welcome") { |
| 534 | }; | 627 | bindEnvironmentId(event.payload.environment.environmentId); |
| 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; | ||
| 542 | } | 628 | } |
| 543 | if (action === "recover") { | 629 | }); |
| 544 | flushPendingDomainEvents(); | 630 | const unsubConfig = entry.client.server.subscribeConfig((event) => { |
| 545 | void runReplayRecovery("sequence-gap"); | 631 | if (event.type === "snapshot") { |
| 632 | bindEnvironmentId(event.config.environment.environmentId); | ||
| 546 | } | 633 | } |
| 547 | }, | 634 | }); |
| 548 | { | 635 | if (boundEnvironmentId === null) { |
| 549 | onResubscribe: () => { | 636 | void entry.client.server |
| 550 | if (disposed) { | 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 | return; | 651 | return; |
| 552 | } | 652 | } |
| 553 | flushPendingDomainEvents(); | 653 | if (action === "recover") { |
| 554 | void runReplayRecovery("resubscribe"); | 654 | flushPendingDomainEvents(); |
| 655 | void runReplayRecovery("sequence-gap"); | ||
| 656 | } | ||
| 555 | }, | 657 | }, |
| 556 | }, | 658 | { |
| 557 | ); | 659 | onResubscribe: () => { |
| 558 | const unsubTerminalEvent = api.terminal.onEvent((event) => { | 660 | if (disposed) { |
| 559 | const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); | 661 | return; |
| 560 | if (thread && thread.archivedAt !== null) { | 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 | return; | 710 | return; |
| 562 | } | 711 | } |
| 563 | applyTerminalEvent(event); | 712 | primaryClientContext.bindEnvironmentId(environmentId); |
| 564 | }); | 713 | await primaryClientContext.runSnapshotRecovery("bootstrap", environmentId); |
| 714 | }; | ||
| 715 | |||
| 565 | return () => { | 716 | return () => { |
| 566 | disposed = true; | 717 | disposed = true; |
| 567 | disposedRef.current = true; | 718 | disposedRef.current = true; |
| 568 | needsProviderInvalidation = false; | 719 | needsProviderInvalidation = false; |
| 569 | flushPendingDomainEventsScheduled = false; | 720 | schedulePendingDomainEventFlushRef.current = () => undefined; |
| 570 | pendingDomainEvents.length = 0; | ||
| 571 | queryInvalidationThrottler.cancel(); | 721 | queryInvalidationThrottler.cancel(); |
| 572 | unsubDomainEvent(); | 722 | for (const context of clientContexts) { |
| 573 | unsubTerminalEvent(); | 723 | context.cleanup(); |
| 724 | } | ||
| 574 | }; | 725 | }; |
| 575 | }, [ | 726 | }, [ |
| 576 | applyOrchestrationEvents, | 727 | applyOrchestrationEvents, |
| 728 | clientEntries, | ||
| 577 | navigate, | 729 | navigate, |
| 578 | queryClient, | 730 | queryClient, |
| 579 | removeTerminalState, | 731 | removeTerminalState, |
| ... | @@ -581,6 +733,7 @@ function EventRouter() { | ... | @@ -581,6 +733,7 @@ function EventRouter() { |
| 581 | applyTerminalEvent, | 733 | applyTerminalEvent, |
| 582 | clearThreadUi, | 734 | clearThreadUi, |
| 583 | setProjectExpanded, | 735 | setProjectExpanded, |
| 736 | setActiveEnvironmentId, | ||
| 584 | syncProjects, | 737 | syncProjects, |
| 585 | syncServerReadModel, | 738 | syncServerReadModel, |
| 586 | syncThreads, | 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,3 +1,4 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime"; | ||
| 1 | import { Outlet, createFileRoute } from "@tanstack/react-router"; | 2 | import { Outlet, createFileRoute } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | 3 | import { useEffect } from "react"; |
| 3 | 4 | ||
| ... | @@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState"; | ... | @@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState"; |
| 12 | 13 | ||
| 13 | function ChatRouteGlobalShortcuts() { | 14 | function ChatRouteGlobalShortcuts() { |
| 14 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); | 15 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| 15 | const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); | 16 | const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); |
| 16 | const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = | 17 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = |
| 17 | useHandleNewThread(); | 18 | useHandleNewThread(); |
| 18 | const keybindings = useServerKeybindings(); | 19 | const keybindings = useServerKeybindings(); |
| 19 | const terminalOpen = useTerminalStateStore((state) => | 20 | const terminalOpen = useTerminalStateStore((state) => |
| 20 | routeThreadId | 21 | routeThreadRef |
| 21 | ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen | 22 | ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen |
| 22 | : false, | 23 | : false, |
| 23 | ); | 24 | ); |
| 24 | const appSettings = useSettings(); | 25 | const appSettings = useSettings(); |
| ... | @@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() { |
| 27 | const onWindowKeyDown = (event: KeyboardEvent) => { | 28 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 28 | if (event.defaultPrevented) return; | 29 | if (event.defaultPrevented) return; |
| 29 | 30 | ||
| 30 | if (event.key === "Escape" && selectedThreadIdsSize > 0) { | 31 | if (event.key === "Escape" && selectedThreadKeysSize > 0) { |
| 31 | event.preventDefault(); | 32 | event.preventDefault(); |
| 32 | clearSelection(); | 33 | clearSelection(); |
| 33 | return; | 34 | return; |
| 34 | } | 35 | } |
| 35 | 36 | ||
| 36 | const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId; | 37 | const projectRef = activeThread |
| 37 | if (!projectId) return; | 38 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| 39 | : activeDraftThread && routeThreadRef | ||
| 40 | ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId) | ||
| 41 | : defaultProjectRef; | ||
| 42 | if (!projectRef) return; | ||
| 38 | 43 | ||
| 39 | const command = resolveShortcutCommand(event, keybindings, { | 44 | const command = resolveShortcutCommand(event, keybindings, { |
| 40 | context: { | 45 | context: { |
| ... | @@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() { |
| 46 | if (command === "chat.newLocal") { | 51 | if (command === "chat.newLocal") { |
| 47 | event.preventDefault(); | 52 | event.preventDefault(); |
| 48 | event.stopPropagation(); | 53 | event.stopPropagation(); |
| 49 | void handleNewThread(projectId, { | 54 | void handleNewThread(projectRef, { |
| 50 | envMode: resolveSidebarNewThreadEnvMode({ | 55 | envMode: resolveSidebarNewThreadEnvMode({ |
| 51 | defaultEnvMode: appSettings.defaultThreadEnvMode, | 56 | defaultEnvMode: appSettings.defaultThreadEnvMode, |
| 52 | }), | 57 | }), |
| ... | @@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() { |
| 57 | if (command === "chat.new") { | 62 | if (command === "chat.new") { |
| 58 | event.preventDefault(); | 63 | event.preventDefault(); |
| 59 | event.stopPropagation(); | 64 | event.stopPropagation(); |
| 60 | void handleNewThread(projectId, { | 65 | void handleNewThread(projectRef, { |
| 61 | branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, | 66 | branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, |
| 62 | worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, | 67 | worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, |
| 63 | envMode: | 68 | envMode: |
| ... | @@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() { |
| 77 | clearSelection, | 82 | clearSelection, |
| 78 | handleNewThread, | 83 | handleNewThread, |
| 79 | keybindings, | 84 | keybindings, |
| 80 | defaultProjectId, | 85 | defaultProjectRef, |
| 81 | selectedThreadIdsSize, | 86 | routeThreadRef, |
| 87 | selectedThreadKeysSize, | ||
| 82 | terminalOpen, | 88 | terminalOpen, |
| 83 | appSettings.defaultThreadEnvMode, | 89 | appSettings.defaultThreadEnvMode, |
| 84 | ]); | 90 | ]); |
apps/web/src/rpc/serverState.test.ts+18| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | EnvironmentId, | ||
| 3 | ProjectId, | 4 | ProjectId, |
| 4 | ThreadId, | 5 | ThreadId, |
| 5 | type ServerConfig, | 6 | type ServerConfig, |
| ... | @@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ | ... | @@ -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 | const baseServerConfig: ServerConfig = { | 67 | const baseServerConfig: ServerConfig = { |
| 68 | environment: baseEnvironment, | ||
| 54 | cwd: "/tmp/workspace", | 69 | cwd: "/tmp/workspace", |
| 55 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | 70 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", |
| 56 | keybindings: [], | 71 | keybindings: [], |
| ... | @@ -193,6 +208,7 @@ describe("serverState", () => { | ... | @@ -193,6 +208,7 @@ describe("serverState", () => { |
| 193 | sequence: 1, | 208 | sequence: 1, |
| 194 | type: "welcome", | 209 | type: "welcome", |
| 195 | payload: { | 210 | payload: { |
| 211 | environment: baseEnvironment, | ||
| 196 | cwd: "/tmp/workspace", | 212 | cwd: "/tmp/workspace", |
| 197 | projectName: "t3-code", | 213 | projectName: "t3-code", |
| 198 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), | 214 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), |
| ... | @@ -201,6 +217,7 @@ describe("serverState", () => { | ... | @@ -201,6 +217,7 @@ describe("serverState", () => { |
| 201 | }); | 217 | }); |
| 202 | 218 | ||
| 203 | expect(listener).toHaveBeenCalledWith({ | 219 | expect(listener).toHaveBeenCalledWith({ |
| 220 | environment: baseEnvironment, | ||
| 204 | cwd: "/tmp/workspace", | 221 | cwd: "/tmp/workspace", |
| 205 | projectName: "t3-code", | 222 | projectName: "t3-code", |
| 206 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), | 223 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), |
| ... | @@ -210,6 +227,7 @@ describe("serverState", () => { | ... | @@ -210,6 +227,7 @@ describe("serverState", () => { |
| 210 | const lateListener = vi.fn(); | 227 | const lateListener = vi.fn(); |
| 211 | const unsubscribeLate = onWelcome(lateListener); | 228 | const unsubscribeLate = onWelcome(lateListener); |
| 212 | expect(lateListener).toHaveBeenCalledWith({ | 229 | expect(lateListener).toHaveBeenCalledWith({ |
| 230 | environment: baseEnvironment, | ||
| 213 | cwd: "/tmp/workspace", | 231 | cwd: "/tmp/workspace", |
| 214 | projectName: "t3-code", | 232 | projectName: "t3-code", |
| 215 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), | 233 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), |
apps/web/src/store.test.ts+166-105| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | CheckpointRef, | 2 | CheckpointRef, |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | EnvironmentId, | ||
| 4 | EventId, | 5 | EventId, |
| 5 | MessageId, | 6 | MessageId, |
| 6 | ProjectId, | 7 | ProjectId, |
| ... | @@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest"; | ... | @@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest"; |
| 14 | import { | 15 | import { |
| 15 | applyOrchestrationEvent, | 16 | applyOrchestrationEvent, |
| 16 | applyOrchestrationEvents, | 17 | applyOrchestrationEvents, |
| 17 | selectProjects, | 18 | selectEnvironmentState, |
| 18 | selectThreads, | 19 | selectProjectsAcrossEnvironments, |
| 20 | selectThreadsAcrossEnvironments, | ||
| 19 | syncServerReadModel, | 21 | syncServerReadModel, |
| 20 | type AppState, | 22 | type AppState, |
| 23 | type EnvironmentState, | ||
| 21 | } from "./store"; | 24 | } from "./store"; |
| 22 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | 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 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 57 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 25 | return { | 58 | return { |
| 26 | id: ThreadId.makeUnsafe("thread-1"), | 59 | id: ThreadId.makeUnsafe("thread-1"), |
| 60 | environmentId: localEnvironmentId, | ||
| 27 | codexThreadId: null, | 61 | codexThreadId: null, |
| 28 | projectId: ProjectId.makeUnsafe("project-1"), | 62 | projectId: ProjectId.makeUnsafe("project-1"), |
| 29 | title: "Thread", | 63 | title: "Thread", |
| ... | @@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState { | ... | @@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState { |
| 52 | const projectId = ProjectId.makeUnsafe("project-1"); | 86 | const projectId = ProjectId.makeUnsafe("project-1"); |
| 53 | const project = { | 87 | const project = { |
| 54 | id: projectId, | 88 | id: projectId, |
| 89 | environmentId: localEnvironmentId, | ||
| 55 | name: "Project", | 90 | name: "Project", |
| 56 | cwd: "/tmp/project", | 91 | cwd: "/tmp/project", |
| 57 | defaultModelSelection: { | 92 | defaultModelSelection: { |
| ... | @@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState { | ... | @@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState { |
| 62 | updatedAt: "2026-02-13T00:00:00.000Z", | 97 | updatedAt: "2026-02-13T00:00:00.000Z", |
| 63 | scripts: [], | 98 | scripts: [], |
| 64 | }; | 99 | }; |
| 65 | const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { | 100 | const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { |
| 66 | [thread.projectId]: [thread.id], | 101 | [thread.projectId]: [thread.id], |
| 67 | }; | 102 | }; |
| 68 | return { | 103 | const environmentState = { |
| 69 | projectIds: [projectId], | 104 | projectIds: [projectId], |
| 70 | projectById: { | 105 | projectById: { |
| 71 | [projectId]: project, | 106 | [projectId]: project, |
| ... | @@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState { | ... | @@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState { |
| 75 | threadShellById: { | 110 | threadShellById: { |
| 76 | [thread.id]: { | 111 | [thread.id]: { |
| 77 | id: thread.id, | 112 | id: thread.id, |
| 113 | environmentId: thread.environmentId, | ||
| 78 | codexThreadId: thread.codexThreadId, | 114 | codexThreadId: thread.codexThreadId, |
| 79 | projectId: thread.projectId, | 115 | projectId: thread.projectId, |
| 80 | title: thread.title, | 116 | title: thread.title, |
| ... | @@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState { | ... | @@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState { |
| 106 | messageByThreadId: { | 142 | messageByThreadId: { |
| 107 | [thread.id]: Object.fromEntries( | 143 | [thread.id]: Object.fromEntries( |
| 108 | thread.messages.map((message) => [message.id, message] as const), | 144 | thread.messages.map((message) => [message.id, message] as const), |
| 109 | ) as AppState["messageByThreadId"][ThreadId], | 145 | ) as EnvironmentState["messageByThreadId"][ThreadId], |
| 110 | }, | 146 | }, |
| 111 | activityIdsByThreadId: { | 147 | activityIdsByThreadId: { |
| 112 | [thread.id]: thread.activities.map((activity) => activity.id), | 148 | [thread.id]: thread.activities.map((activity) => activity.id), |
| ... | @@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState { | ... | @@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState { |
| 114 | activityByThreadId: { | 150 | activityByThreadId: { |
| 115 | [thread.id]: Object.fromEntries( | 151 | [thread.id]: Object.fromEntries( |
| 116 | thread.activities.map((activity) => [activity.id, activity] as const), | 152 | thread.activities.map((activity) => [activity.id, activity] as const), |
| 117 | ) as AppState["activityByThreadId"][ThreadId], | 153 | ) as EnvironmentState["activityByThreadId"][ThreadId], |
| 118 | }, | 154 | }, |
| 119 | proposedPlanIdsByThreadId: { | 155 | proposedPlanIdsByThreadId: { |
| 120 | [thread.id]: thread.proposedPlans.map((plan) => plan.id), | 156 | [thread.id]: thread.proposedPlans.map((plan) => plan.id), |
| ... | @@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState { | ... | @@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState { |
| 122 | proposedPlanByThreadId: { | 158 | proposedPlanByThreadId: { |
| 123 | [thread.id]: Object.fromEntries( | 159 | [thread.id]: Object.fromEntries( |
| 124 | thread.proposedPlans.map((plan) => [plan.id, plan] as const), | 160 | thread.proposedPlans.map((plan) => [plan.id, plan] as const), |
| 125 | ) as AppState["proposedPlanByThreadId"][ThreadId], | 161 | ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], |
| 126 | }, | 162 | }, |
| 127 | turnDiffIdsByThreadId: { | 163 | turnDiffIdsByThreadId: { |
| 128 | [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), | 164 | [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), |
| ... | @@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState { | ... | @@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState { |
| 130 | turnDiffSummaryByThreadId: { | 166 | turnDiffSummaryByThreadId: { |
| 131 | [thread.id]: Object.fromEntries( | 167 | [thread.id]: Object.fromEntries( |
| 132 | thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), | 168 | thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), |
| 133 | ) as AppState["turnDiffSummaryByThreadId"][ThreadId], | 169 | ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId], |
| 134 | }, | 170 | }, |
| 135 | sidebarThreadSummaryById: {}, | 171 | sidebarThreadSummaryById: {}, |
| 136 | bootstrapComplete: true, | 172 | bootstrapComplete: true, |
| 137 | }; | 173 | }; |
| 174 | return withActiveEnvironmentState(environmentState); | ||
| 138 | } | 175 | } |
| 139 | 176 | ||
| 140 | function makeEmptyState(overrides: Partial<AppState> = {}): AppState { | 177 | function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState { |
| 141 | return { | 178 | const environmentState: EnvironmentState = { |
| 142 | projectIds: [], | 179 | projectIds: [], |
| 143 | projectById: {}, | 180 | projectById: {}, |
| 144 | threadIds: [], | 181 | threadIds: [], |
| ... | @@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial<AppState> = {}): AppState { | ... | @@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial<AppState> = {}): AppState { |
| 156 | turnDiffSummaryByThreadId: {}, | 193 | turnDiffSummaryByThreadId: {}, |
| 157 | sidebarThreadSummaryById: {}, | 194 | sidebarThreadSummaryById: {}, |
| 158 | bootstrapComplete: true, | 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 | function projectsOf(state: AppState) { | 204 | function projectsOf(state: AppState) { |
| 164 | return selectProjects(state); | 205 | return selectProjectsAcrossEnvironments(state); |
| 165 | } | 206 | } |
| 166 | 207 | ||
| 167 | function threadsOf(state: AppState) { | 208 | function threadsOf(state: AppState) { |
| 168 | return selectThreads(state); | 209 | return selectThreadsAcrossEnvironments(state); |
| 169 | } | 210 | } |
| 170 | 211 | ||
| 171 | function makeEvent<T extends OrchestrationEvent["type"]>( | 212 | function makeEvent<T extends OrchestrationEvent["type"]>( |
| ... | @@ -266,14 +307,20 @@ function makeReadModelProject( | ... | @@ -266,14 +307,20 @@ function makeReadModelProject( |
| 266 | 307 | ||
| 267 | describe("store read model sync", () => { | 308 | describe("store read model sync", () => { |
| 268 | it("marks bootstrap complete after snapshot sync", () => { | 309 | it("marks bootstrap complete after snapshot sync", () => { |
| 269 | const initialState: AppState = { | 310 | const initialState = withActiveEnvironmentState( |
| 270 | ...makeState(makeThread()), | 311 | localEnvironmentStateOf(makeState(makeThread())), |
| 271 | bootstrapComplete: false, | 312 | { |
| 272 | }; | 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 | it("preserves claude model slugs without an active session", () => { | 326 | it("preserves claude model slugs without an active session", () => { |
| ... | @@ -287,7 +334,7 @@ describe("store read model sync", () => { | ... | @@ -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 | expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); | 339 | expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); |
| 293 | }); | 340 | }); |
| ... | @@ -312,7 +359,7 @@ describe("store read model sync", () => { | ... | @@ -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 | expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); | 364 | expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); |
| 318 | }); | 365 | }); |
| ... | @@ -325,7 +372,7 @@ describe("store read model sync", () => { | ... | @@ -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 | expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); | 377 | expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); |
| 331 | expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); | 378 | expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); |
| ... | @@ -341,6 +388,7 @@ describe("store read model sync", () => { | ... | @@ -341,6 +388,7 @@ describe("store read model sync", () => { |
| 341 | archivedAt, | 388 | archivedAt, |
| 342 | }), | 389 | }), |
| 343 | ), | 390 | ), |
| 391 | localEnvironmentId, | ||
| 344 | ); | 392 | ); |
| 345 | 393 | ||
| 346 | expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); | 394 | expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); |
| ... | @@ -355,6 +403,7 @@ describe("store read model sync", () => { | ... | @@ -355,6 +403,7 @@ describe("store read model sync", () => { |
| 355 | projectById: { | 403 | projectById: { |
| 356 | [project2]: { | 404 | [project2]: { |
| 357 | id: project2, | 405 | id: project2, |
| 406 | environmentId: localEnvironmentId, | ||
| 358 | name: "Project 2", | 407 | name: "Project 2", |
| 359 | cwd: "/tmp/project-2", | 408 | cwd: "/tmp/project-2", |
| 360 | defaultModelSelection: { | 409 | defaultModelSelection: { |
| ... | @@ -367,6 +416,7 @@ describe("store read model sync", () => { | ... | @@ -367,6 +416,7 @@ describe("store read model sync", () => { |
| 367 | }, | 416 | }, |
| 368 | [project1]: { | 417 | [project1]: { |
| 369 | id: project1, | 418 | id: project1, |
| 419 | environmentId: localEnvironmentId, | ||
| 370 | name: "Project 1", | 420 | name: "Project 1", |
| 371 | cwd: "/tmp/project-1", | 421 | cwd: "/tmp/project-1", |
| 372 | defaultModelSelection: { | 422 | defaultModelSelection: { |
| ... | @@ -402,7 +452,7 @@ describe("store read model sync", () => { | ... | @@ -402,7 +452,7 @@ describe("store read model sync", () => { |
| 402 | threads: [], | 452 | threads: [], |
| 403 | }; | 453 | }; |
| 404 | 454 | ||
| 405 | const next = syncServerReadModel(initialState, readModel); | 455 | const next = syncServerReadModel(initialState, readModel, localEnvironmentId); |
| 406 | 456 | ||
| 407 | expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); | 457 | expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); |
| 408 | }); | 458 | }); |
| ... | @@ -410,10 +460,9 @@ describe("store read model sync", () => { | ... | @@ -410,10 +460,9 @@ describe("store read model sync", () => { |
| 410 | 460 | ||
| 411 | describe("incremental orchestration updates", () => { | 461 | describe("incremental orchestration updates", () => { |
| 412 | it("does not mark bootstrap complete for incremental events", () => { | 462 | it("does not mark bootstrap complete for incremental events", () => { |
| 413 | const state: AppState = { | 463 | const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { |
| 414 | ...makeState(makeThread()), | ||
| 415 | bootstrapComplete: false, | 464 | bootstrapComplete: false, |
| 416 | }; | 465 | }); |
| 417 | 466 | ||
| 418 | const next = applyOrchestrationEvent( | 467 | const next = applyOrchestrationEvent( |
| 419 | state, | 468 | state, |
| ... | @@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => { | ... | @@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => { |
| 422 | title: "Updated title", | 471 | title: "Updated title", |
| 423 | updatedAt: "2026-02-27T00:00:01.000Z", | 472 | updatedAt: "2026-02-27T00:00:01.000Z", |
| 424 | }), | 473 | }), |
| 474 | localEnvironmentId, | ||
| 425 | ); | 475 | ); |
| 426 | 476 | ||
| 427 | expect(next.bootstrapComplete).toBe(false); | 477 | expect(localEnvironmentStateOf(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"); | ||
| 449 | }); | 478 | }); |
| 450 | 479 | ||
| 451 | it("preserves state identity for no-op project and thread deletes", () => { | 480 | it("preserves state identity for no-op project and thread deletes", () => { |
| ... | @@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => { |
| 458 | projectId: ProjectId.makeUnsafe("project-missing"), | 487 | projectId: ProjectId.makeUnsafe("project-missing"), |
| 459 | deletedAt: "2026-02-27T00:00:01.000Z", | 488 | deletedAt: "2026-02-27T00:00:01.000Z", |
| 460 | }), | 489 | }), |
| 490 | localEnvironmentId, | ||
| 461 | ); | 491 | ); |
| 462 | const nextAfterThreadDelete = applyOrchestrationEvent( | 492 | const nextAfterThreadDelete = applyOrchestrationEvent( |
| 463 | state, | 493 | state, |
| ... | @@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => { |
| 465 | threadId: ThreadId.makeUnsafe("thread-missing"), | 495 | threadId: ThreadId.makeUnsafe("thread-missing"), |
| 466 | deletedAt: "2026-02-27T00:00:01.000Z", | 496 | deletedAt: "2026-02-27T00:00:01.000Z", |
| 467 | }), | 497 | }), |
| 498 | localEnvironmentId, | ||
| 468 | ); | 499 | ); |
| 469 | 500 | ||
| 470 | expect(nextAfterProjectDelete).toBe(state); | 501 | expect(nextAfterProjectDelete).toBe(state); |
| ... | @@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => { |
| 479 | projectById: { | 510 | projectById: { |
| 480 | [originalProjectId]: { | 511 | [originalProjectId]: { |
| 481 | id: originalProjectId, | 512 | id: originalProjectId, |
| 513 | environmentId: localEnvironmentId, | ||
| 482 | name: "Project", | 514 | name: "Project", |
| 483 | cwd: "/tmp/project", | 515 | cwd: "/tmp/project", |
| 484 | defaultModelSelection: { | 516 | defaultModelSelection: { |
| ... | @@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => { | ... | @@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => { |
| 506 | createdAt: "2026-02-27T00:00:01.000Z", | 538 | createdAt: "2026-02-27T00:00:01.000Z", |
| 507 | updatedAt: "2026-02-27T00:00:01.000Z", | 539 | updatedAt: "2026-02-27T00:00:01.000Z", |
| 508 | }), | 540 | }), |
| 541 | localEnvironmentId, | ||
| 509 | ); | 542 | ); |
| 510 | 543 | ||
| 511 | expect(projectsOf(next)).toHaveLength(1); | 544 | expect(projectsOf(next)).toHaveLength(1); |
| 512 | expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); | 545 | expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); |
| 513 | expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); | 546 | expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); |
| 514 | expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); | 547 | expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); |
| 515 | expect(next.projectIds).toEqual([recreatedProjectId]); | 548 | expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]); |
| 516 | expect(next.projectById[originalProjectId]).toBeUndefined(); | 549 | expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined(); |
| 517 | expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId); | 550 | expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe( |
| 551 | recreatedProjectId, | ||
| 552 | ); | ||
| 518 | }); | 553 | }); |
| 519 | 554 | ||
| 520 | it("removes stale project index entries when thread.created recreates a thread under a new project", () => { | 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,12 +560,12 @@ describe("incremental orchestration updates", () => { |
| 525 | id: threadId, | 560 | id: threadId, |
| 526 | projectId: originalProjectId, | 561 | projectId: originalProjectId, |
| 527 | }); | 562 | }); |
| 528 | const state: AppState = { | 563 | const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), { |
| 529 | ...makeState(thread), | ||
| 530 | projectIds: [originalProjectId, recreatedProjectId], | 564 | projectIds: [originalProjectId, recreatedProjectId], |
| 531 | projectById: { | 565 | projectById: { |
| 532 | [originalProjectId]: { | 566 | [originalProjectId]: { |
| 533 | id: originalProjectId, | 567 | id: originalProjectId, |
| 568 | environmentId: localEnvironmentId, | ||
| 534 | name: "Project 1", | 569 | name: "Project 1", |
| 535 | cwd: "/tmp/project-1", | 570 | cwd: "/tmp/project-1", |
| 536 | defaultModelSelection: { | 571 | defaultModelSelection: { |
| ... | @@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => { |
| 543 | }, | 578 | }, |
| 544 | [recreatedProjectId]: { | 579 | [recreatedProjectId]: { |
| 545 | id: recreatedProjectId, | 580 | id: recreatedProjectId, |
| 581 | environmentId: localEnvironmentId, | ||
| 546 | name: "Project 2", | 582 | name: "Project 2", |
| 547 | cwd: "/tmp/project-2", | 583 | cwd: "/tmp/project-2", |
| 548 | defaultModelSelection: { | 584 | defaultModelSelection: { |
| ... | @@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => { |
| 554 | scripts: [], | 590 | scripts: [], |
| 555 | }, | 591 | }, |
| 556 | }, | 592 | }, |
| 557 | }; | 593 | }); |
| 558 | 594 | ||
| 559 | const next = applyOrchestrationEvent( | 595 | const next = applyOrchestrationEvent( |
| 560 | state, | 596 | state, |
| ... | @@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => { | ... | @@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => { |
| 573 | createdAt: "2026-02-27T00:00:01.000Z", | 609 | createdAt: "2026-02-27T00:00:01.000Z", |
| 574 | updatedAt: "2026-02-27T00:00:01.000Z", | 610 | updatedAt: "2026-02-27T00:00:01.000Z", |
| 575 | }), | 611 | }), |
| 612 | localEnvironmentId, | ||
| 576 | ); | 613 | ); |
| 577 | 614 | ||
| 578 | expect(threadsOf(next)).toHaveLength(1); | 615 | expect(threadsOf(next)).toHaveLength(1); |
| 579 | expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); | 616 | expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); |
| 580 | expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); | 617 | expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined(); |
| 581 | expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); | 618 | expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([ |
| 619 | threadId, | ||
| 620 | ]); | ||
| 582 | }); | 621 | }); |
| 583 | 622 | ||
| 584 | it("updates only the affected thread for message events", () => { | 623 | it("updates only the affected thread for message events", () => { |
| ... | @@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => { | ... | @@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => { |
| 597 | ], | 636 | ], |
| 598 | }); | 637 | }); |
| 599 | const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); | 638 | const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); |
| 600 | const state: AppState = { | 639 | const baseState = makeState(thread1); |
| 601 | ...makeState(thread1), | 640 | const baseEnvironmentState = localEnvironmentStateOf(baseState); |
| 641 | const state = withActiveEnvironmentState(baseEnvironmentState, { | ||
| 602 | threadIds: [thread1.id, thread2.id], | 642 | threadIds: [thread1.id, thread2.id], |
| 603 | threadShellById: { | 643 | threadShellById: { |
| 604 | ...makeState(thread1).threadShellById, | 644 | ...baseEnvironmentState.threadShellById, |
| 605 | [thread2.id]: { | 645 | [thread2.id]: { |
| 606 | id: thread2.id, | 646 | id: thread2.id, |
| 647 | environmentId: thread2.environmentId, | ||
| 607 | codexThreadId: thread2.codexThreadId, | 648 | codexThreadId: thread2.codexThreadId, |
| 608 | projectId: thread2.projectId, | 649 | projectId: thread2.projectId, |
| 609 | title: thread2.title, | 650 | title: thread2.title, |
| ... | @@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => { | ... | @@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => { |
| 619 | }, | 660 | }, |
| 620 | }, | 661 | }, |
| 621 | threadSessionById: { | 662 | threadSessionById: { |
| 622 | ...makeState(thread1).threadSessionById, | 663 | ...baseEnvironmentState.threadSessionById, |
| 623 | [thread2.id]: thread2.session, | 664 | [thread2.id]: thread2.session, |
| 624 | }, | 665 | }, |
| 625 | threadTurnStateById: { | 666 | threadTurnStateById: { |
| 626 | ...makeState(thread1).threadTurnStateById, | 667 | ...baseEnvironmentState.threadTurnStateById, |
| 627 | [thread2.id]: { | 668 | [thread2.id]: { |
| 628 | latestTurn: thread2.latestTurn, | 669 | latestTurn: thread2.latestTurn, |
| 629 | }, | 670 | }, |
| 630 | }, | 671 | }, |
| 631 | messageIdsByThreadId: { | 672 | messageIdsByThreadId: { |
| 632 | ...makeState(thread1).messageIdsByThreadId, | 673 | ...baseEnvironmentState.messageIdsByThreadId, |
| 633 | [thread2.id]: [], | 674 | [thread2.id]: [], |
| 634 | }, | 675 | }, |
| 635 | messageByThreadId: { | 676 | messageByThreadId: { |
| 636 | ...makeState(thread1).messageByThreadId, | 677 | ...baseEnvironmentState.messageByThreadId, |
| 637 | [thread2.id]: {}, | 678 | [thread2.id]: {}, |
| 638 | }, | 679 | }, |
| 639 | activityIdsByThreadId: { | 680 | activityIdsByThreadId: { |
| 640 | ...makeState(thread1).activityIdsByThreadId, | 681 | ...baseEnvironmentState.activityIdsByThreadId, |
| 641 | [thread2.id]: [], | 682 | [thread2.id]: [], |
| 642 | }, | 683 | }, |
| 643 | activityByThreadId: { | 684 | activityByThreadId: { |
| 644 | ...makeState(thread1).activityByThreadId, | 685 | ...baseEnvironmentState.activityByThreadId, |
| 645 | [thread2.id]: {}, | 686 | [thread2.id]: {}, |
| 646 | }, | 687 | }, |
| 647 | proposedPlanIdsByThreadId: { | 688 | proposedPlanIdsByThreadId: { |
| 648 | ...makeState(thread1).proposedPlanIdsByThreadId, | 689 | ...baseEnvironmentState.proposedPlanIdsByThreadId, |
| 649 | [thread2.id]: [], | 690 | [thread2.id]: [], |
| 650 | }, | 691 | }, |
| 651 | proposedPlanByThreadId: { | 692 | proposedPlanByThreadId: { |
| 652 | ...makeState(thread1).proposedPlanByThreadId, | 693 | ...baseEnvironmentState.proposedPlanByThreadId, |
| 653 | [thread2.id]: {}, | 694 | [thread2.id]: {}, |
| 654 | }, | 695 | }, |
| 655 | turnDiffIdsByThreadId: { | 696 | turnDiffIdsByThreadId: { |
| 656 | ...makeState(thread1).turnDiffIdsByThreadId, | 697 | ...baseEnvironmentState.turnDiffIdsByThreadId, |
| 657 | [thread2.id]: [], | 698 | [thread2.id]: [], |
| 658 | }, | 699 | }, |
| 659 | turnDiffSummaryByThreadId: { | 700 | turnDiffSummaryByThreadId: { |
| 660 | ...makeState(thread1).turnDiffSummaryByThreadId, | 701 | ...baseEnvironmentState.turnDiffSummaryByThreadId, |
| 661 | [thread2.id]: {}, | 702 | [thread2.id]: {}, |
| 662 | }, | 703 | }, |
| 663 | sidebarThreadSummaryById: { | 704 | sidebarThreadSummaryById: { |
| 664 | ...makeState(thread1).sidebarThreadSummaryById, | 705 | ...baseEnvironmentState.sidebarThreadSummaryById, |
| 665 | }, | 706 | }, |
| 666 | threadIdsByProjectId: { | 707 | threadIdsByProjectId: { |
| 667 | [thread1.projectId]: [thread1.id, thread2.id], | 708 | [thread1.projectId]: [thread1.id, thread2.id], |
| 668 | }, | 709 | }, |
| 669 | }; | 710 | }); |
| 670 | 711 | ||
| 671 | const next = applyOrchestrationEvent( | 712 | const next = applyOrchestrationEvent( |
| 672 | state, | 713 | state, |
| ... | @@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => { | ... | @@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => { |
| 680 | createdAt: "2026-02-27T00:00:01.000Z", | 721 | createdAt: "2026-02-27T00:00:01.000Z", |
| 681 | updatedAt: "2026-02-27T00:00:01.000Z", | 722 | updatedAt: "2026-02-27T00:00:01.000Z", |
| 682 | }), | 723 | }), |
| 724 | localEnvironmentId, | ||
| 683 | ); | 725 | ); |
| 684 | 726 | ||
| 685 | expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); | 727 | expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); |
| 686 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); | 728 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); |
| 687 | expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]); | 729 | const nextEnvironmentState = next.environmentStateById[localEnvironmentId]; |
| 688 | expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]); | 730 | const previousEnvironmentState = state.environmentStateById[localEnvironmentId]; |
| 689 | expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]); | 731 | expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe( |
| 690 | expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]); | 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 | it("applies replay batches in sequence and updates session state", () => { | 745 | it("applies replay batches in sequence and updates session state", () => { |
| ... | @@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => { | ... | @@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => { |
| 703 | }); | 755 | }); |
| 704 | const state = makeState(thread); | 756 | const state = makeState(thread); |
| 705 | 757 | ||
| 706 | const next = applyOrchestrationEvents(state, [ | 758 | const next = applyOrchestrationEvents( |
| 707 | makeEvent( | 759 | state, |
| 708 | "thread.session-set", | 760 | [ |
| 709 | { | 761 | makeEvent( |
| 710 | threadId: thread.id, | 762 | "thread.session-set", |
| 711 | session: { | 763 | { |
| 712 | threadId: thread.id, | 764 | threadId: thread.id, |
| 713 | status: "running", | 765 | session: { |
| 714 | providerName: "codex", | 766 | threadId: thread.id, |
| 715 | runtimeMode: "full-access", | 767 | status: "running", |
| 716 | activeTurnId: TurnId.makeUnsafe("turn-1"), | 768 | providerName: "codex", |
| 717 | lastError: null, | 769 | runtimeMode: "full-access", |
| 718 | updatedAt: "2026-02-27T00:00:02.000Z", | 770 | activeTurnId: TurnId.makeUnsafe("turn-1"), |
| 771 | lastError: null, | ||
| 772 | updatedAt: "2026-02-27T00:00:02.000Z", | ||
| 773 | }, | ||
| 719 | }, | 774 | }, |
| 720 | }, | 775 | { sequence: 2 }, |
| 721 | { sequence: 2 }, | 776 | ), |
| 722 | ), | 777 | makeEvent( |
| 723 | makeEvent( | 778 | "thread.message-sent", |
| 724 | "thread.message-sent", | 779 | { |
| 725 | { | 780 | threadId: thread.id, |
| 726 | threadId: thread.id, | 781 | messageId: MessageId.makeUnsafe("assistant-1"), |
| 727 | messageId: MessageId.makeUnsafe("assistant-1"), | 782 | role: "assistant", |
| 728 | role: "assistant", | 783 | text: "done", |
| 729 | text: "done", | 784 | turnId: TurnId.makeUnsafe("turn-1"), |
| 730 | turnId: TurnId.makeUnsafe("turn-1"), | 785 | streaming: false, |
| 731 | streaming: false, | 786 | createdAt: "2026-02-27T00:00:03.000Z", |
| 732 | createdAt: "2026-02-27T00:00:03.000Z", | 787 | updatedAt: "2026-02-27T00:00:03.000Z", |
| 733 | updatedAt: "2026-02-27T00:00:03.000Z", | 788 | }, |
| 734 | }, | 789 | { sequence: 3 }, |
| 735 | { sequence: 3 }, | 790 | ), |
| 736 | ), | 791 | ], |
| 737 | ]); | 792 | localEnvironmentId, |
| 793 | ); | ||
| 738 | 794 | ||
| 739 | expect(threadsOf(next)[0]?.session?.status).toBe("running"); | 795 | expect(threadsOf(next)[0]?.session?.status).toBe("running"); |
| 740 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); | 796 | expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); |
| ... | @@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => { |
| 767 | assistantMessageId: MessageId.makeUnsafe("assistant-1"), | 823 | assistantMessageId: MessageId.makeUnsafe("assistant-1"), |
| 768 | completedAt: "2026-02-27T00:00:04.000Z", | 824 | completedAt: "2026-02-27T00:00:04.000Z", |
| 769 | }), | 825 | }), |
| 826 | localEnvironmentId, | ||
| 770 | ); | 827 | ); |
| 771 | 828 | ||
| 772 | expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); | 829 | expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); |
| ... | @@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => { |
| 811 | createdAt: "2026-02-27T00:00:03.000Z", | 868 | createdAt: "2026-02-27T00:00:03.000Z", |
| 812 | updatedAt: "2026-02-27T00:00:03.000Z", | 869 | updatedAt: "2026-02-27T00:00:03.000Z", |
| 813 | }), | 870 | }), |
| 871 | localEnvironmentId, | ||
| 814 | ); | 872 | ); |
| 815 | 873 | ||
| 816 | expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( | 874 | expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( |
| ... | @@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => { |
| 920 | threadId: ThreadId.makeUnsafe("thread-1"), | 978 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 921 | turnCount: 1, | 979 | turnCount: 1, |
| 922 | }), | 980 | }), |
| 981 | localEnvironmentId, | ||
| 923 | ); | 982 | ); |
| 924 | 983 | ||
| 925 | expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ | 984 | expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ |
| ... | @@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => { |
| 978 | threadId: thread.id, | 1037 | threadId: thread.id, |
| 979 | turnCount: 1, | 1038 | turnCount: 1, |
| 980 | }), | 1039 | }), |
| 1040 | localEnvironmentId, | ||
| 981 | ); | 1041 | ); |
| 982 | 1042 | ||
| 983 | expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); | 1043 | expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); |
| ... | @@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => { | ... | @@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => { |
| 996 | updatedAt: "2026-02-27T00:00:04.000Z", | 1056 | updatedAt: "2026-02-27T00:00:04.000Z", |
| 997 | }, | 1057 | }, |
| 998 | }), | 1058 | }), |
| 1059 | localEnvironmentId, | ||
| 999 | ); | 1060 | ); |
| 1000 | 1061 | ||
| 1001 | expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ | 1062 | expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ |
apps/web/src/store.ts+336-92| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | ||
| 2 | type MessageId, | 3 | type MessageId, |
| 3 | type OrchestrationCheckpointSummary, | 4 | type OrchestrationCheckpointSummary, |
| 4 | type OrchestrationEvent, | 5 | type OrchestrationEvent, |
| ... | @@ -11,6 +12,8 @@ import { | ... | @@ -11,6 +12,8 @@ import { |
| 11 | type OrchestrationThreadActivity, | 12 | type OrchestrationThreadActivity, |
| 12 | type ProjectId, | 13 | type ProjectId, |
| 13 | type ProviderKind, | 14 | type ProviderKind, |
| 15 | type ScopedProjectRef, | ||
| 16 | type ScopedThreadRef, | ||
| 14 | ThreadId, | 17 | ThreadId, |
| 15 | type TurnId, | 18 | type TurnId, |
| 16 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| ... | @@ -35,7 +38,7 @@ import { | ... | @@ -35,7 +38,7 @@ import { |
| 35 | } from "./types"; | 38 | } from "./types"; |
| 36 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; | 39 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; |
| 37 | 40 | ||
| 38 | export interface AppState { | 41 | export interface EnvironmentState { |
| 39 | projectIds: ProjectId[]; | 42 | projectIds: ProjectId[]; |
| 40 | projectById: Record<ProjectId, Project>; | 43 | projectById: Record<ProjectId, Project>; |
| 41 | threadIds: ThreadId[]; | 44 | threadIds: ThreadId[]; |
| ... | @@ -55,7 +58,12 @@ export interface AppState { | ... | @@ -55,7 +58,12 @@ export interface AppState { |
| 55 | bootstrapComplete: boolean; | 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 | projectIds: [], | 67 | projectIds: [], |
| 60 | projectById: {}, | 68 | projectById: {}, |
| 61 | threadIds: [], | 69 | threadIds: [], |
| ... | @@ -75,6 +83,11 @@ const initialState: AppState = { | ... | @@ -75,6 +83,11 @@ const initialState: AppState = { |
| 75 | bootstrapComplete: false, | 83 | bootstrapComplete: false, |
| 76 | }; | 84 | }; |
| 77 | 85 | ||
| 86 | const initialState: AppState = { | ||
| 87 | activeEnvironmentId: null, | ||
| 88 | environmentStateById: {}, | ||
| 89 | }; | ||
| 90 | |||
| 78 | const MAX_THREAD_MESSAGES = 2_000; | 91 | const MAX_THREAD_MESSAGES = 2_000; |
| 79 | const MAX_THREAD_CHECKPOINTS = 500; | 92 | const MAX_THREAD_CHECKPOINTS = 500; |
| 80 | const MAX_THREAD_PROPOSED_PLANS = 200; | 93 | const MAX_THREAD_PROPOSED_PLANS = 200; |
| ... | @@ -169,11 +182,16 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif | ... | @@ -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 | return { | 189 | return { |
| 174 | id: project.id, | 190 | id: project.id, |
| 191 | environmentId, | ||
| 175 | name: project.title, | 192 | name: project.title, |
| 176 | cwd: project.workspaceRoot, | 193 | cwd: project.workspaceRoot, |
| 194 | repositoryIdentity: project.repositoryIdentity ?? null, | ||
| 177 | defaultModelSelection: project.defaultModelSelection | 195 | defaultModelSelection: project.defaultModelSelection |
| 178 | ? normalizeModelSelection(project.defaultModelSelection) | 196 | ? normalizeModelSelection(project.defaultModelSelection) |
| 179 | : null, | 197 | : null, |
| ... | @@ -183,9 +201,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec | ... | @@ -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 | return { | 205 | return { |
| 188 | id: thread.id, | 206 | id: thread.id, |
| 207 | environmentId, | ||
| 189 | codexThreadId: null, | 208 | codexThreadId: null, |
| 190 | projectId: thread.projectId, | 209 | projectId: thread.projectId, |
| 191 | title: thread.title, | 210 | title: thread.title, |
| ... | @@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread { | ... | @@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread { |
| 211 | function toThreadShell(thread: Thread): ThreadShell { | 230 | function toThreadShell(thread: Thread): ThreadShell { |
| 212 | return { | 231 | return { |
| 213 | id: thread.id, | 232 | id: thread.id, |
| 233 | environmentId: thread.environmentId, | ||
| 214 | codexThreadId: thread.codexThreadId, | 234 | codexThreadId: thread.codexThreadId, |
| 215 | projectId: thread.projectId, | 235 | projectId: thread.projectId, |
| 216 | title: thread.title, | 236 | title: thread.title, |
| ... | @@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | | ... | @@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | |
| 251 | function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { | 271 | function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { |
| 252 | return { | 272 | return { |
| 253 | id: thread.id, | 273 | id: thread.id, |
| 274 | environmentId: thread.environmentId, | ||
| 254 | projectId: thread.projectId, | 275 | projectId: thread.projectId, |
| 255 | title: thread.title, | 276 | title: thread.title, |
| 256 | interactionMode: thread.interactionMode, | 277 | interactionMode: thread.interactionMode, |
| ... | @@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b | ... | @@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b |
| 298 | return ( | 319 | return ( |
| 299 | left !== undefined && | 320 | left !== undefined && |
| 300 | left.id === right.id && | 321 | left.id === right.id && |
| 322 | left.environmentId === right.environmentId && | ||
| 301 | left.codexThreadId === right.codexThreadId && | 323 | left.codexThreadId === right.codexThreadId && |
| 302 | left.projectId === right.projectId && | 324 | left.projectId === right.projectId && |
| 303 | left.title === right.title && | 325 | left.title === right.title && |
| ... | @@ -377,7 +399,7 @@ function buildTurnDiffSlice(thread: Thread): { | ... | @@ -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 | const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; | 403 | const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; |
| 382 | const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; | 404 | const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; |
| 383 | if (ids.length === 0) { | 405 | if (ids.length === 0) { |
| ... | @@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ | ... | @@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ |
| 390 | } | 412 | } |
| 391 | 413 | ||
| 392 | function selectThreadActivities( | 414 | function selectThreadActivities( |
| 393 | state: AppState, | 415 | state: EnvironmentState, |
| 394 | threadId: ThreadId, | 416 | threadId: ThreadId, |
| 395 | ): OrchestrationThreadActivity[] { | 417 | ): OrchestrationThreadActivity[] { |
| 396 | const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; | 418 | const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; |
| ... | @@ -404,7 +426,7 @@ function selectThreadActivities( | ... | @@ -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 | const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; | 430 | const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; |
| 409 | const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; | 431 | const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; |
| 410 | if (ids.length === 0) { | 432 | if (ids.length === 0) { |
| ... | @@ -416,7 +438,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose | ... | @@ -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 | const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; | 445 | const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; |
| 421 | const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; | 446 | const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; |
| 422 | if (ids.length === 0) { | 447 | if (ids.length === 0) { |
| ... | @@ -428,7 +453,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur | ... | @@ -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 | const shell = state.threadShellById[threadId]; | 457 | const shell = state.threadShellById[threadId]; |
| 433 | if (!shell) { | 458 | if (!shell) { |
| 434 | return undefined; | 459 | return undefined; |
| ... | @@ -446,21 +471,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | 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 | return state.projectIds.flatMap((projectId) => { | 475 | return state.projectIds.flatMap((projectId) => { |
| 451 | const project = state.projectById[projectId]; | 476 | const project = state.projectById[projectId]; |
| 452 | return project ? [project] : []; | 477 | return project ? [project] : []; |
| 453 | }); | 478 | }); |
| 454 | } | 479 | } |
| 455 | 480 | ||
| 456 | function getThreads(state: AppState): Thread[] { | 481 | function getThreads(state: EnvironmentState): Thread[] { |
| 457 | return state.threadIds.flatMap((threadId) => { | 482 | return state.threadIds.flatMap((threadId) => { |
| 458 | const thread = getThread(state, threadId); | 483 | const thread = getThread(state, threadId); |
| 459 | return thread ? [thread] : []; | 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 | const nextShell = toThreadShell(nextThread); | 493 | const nextShell = toThreadShell(nextThread); |
| 465 | const nextTurnState = toThreadTurnState(nextThread); | 494 | const nextTurnState = toThreadTurnState(nextThread); |
| 466 | const previousShell = state.threadShellById[nextThread.id]; | 495 | const previousShell = state.threadShellById[nextThread.id]; |
| ... | @@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: | ... | @@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: |
| 613 | return nextState; | 642 | return nextState; |
| 614 | } | 643 | } |
| 615 | 644 | ||
| 616 | function removeThreadState(state: AppState, threadId: ThreadId): AppState { | 645 | function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { |
| 617 | const shell = state.threadShellById[threadId]; | 646 | const shell = state.threadShellById[threadId]; |
| 618 | if (!shell) { | 647 | if (!shell) { |
| 619 | return state; | 648 | return state; |
| ... | @@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { | ... | @@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { |
| 887 | } | 916 | } |
| 888 | 917 | ||
| 889 | function updateThreadState( | 918 | function updateThreadState( |
| 890 | state: AppState, | 919 | state: EnvironmentState, |
| 891 | threadId: ThreadId, | 920 | threadId: ThreadId, |
| 892 | updater: (thread: Thread) => Thread, | 921 | updater: (thread: Thread) => Thread, |
| 893 | ): AppState { | 922 | ): EnvironmentState { |
| 894 | const currentThread = getThread(state, threadId); | 923 | const currentThread = getThread(state, threadId); |
| 895 | if (!currentThread) { | 924 | if (!currentThread) { |
| 896 | return state; | 925 | return state; |
| ... | @@ -904,7 +933,7 @@ function updateThreadState( | ... | @@ -904,7 +933,7 @@ function updateThreadState( |
| 904 | 933 | ||
| 905 | function buildProjectState( | 934 | function buildProjectState( |
| 906 | projects: ReadonlyArray<Project>, | 935 | projects: ReadonlyArray<Project>, |
| 907 | ): Pick<AppState, "projectIds" | "projectById"> { | 936 | ): Pick<EnvironmentState, "projectIds" | "projectById"> { |
| 908 | return { | 937 | return { |
| 909 | projectIds: projects.map((project) => project.id), | 938 | projectIds: projects.map((project) => project.id), |
| 910 | projectById: Object.fromEntries( | 939 | projectById: Object.fromEntries( |
| ... | @@ -916,7 +945,7 @@ function buildProjectState( | ... | @@ -916,7 +945,7 @@ function buildProjectState( |
| 916 | function buildThreadState( | 945 | function buildThreadState( |
| 917 | threads: ReadonlyArray<Thread>, | 946 | threads: ReadonlyArray<Thread>, |
| 918 | ): Pick< | 947 | ): Pick< |
| 919 | AppState, | 948 | EnvironmentState, |
| 920 | | "threadIds" | 949 | | "threadIds" |
| 921 | | "threadIdsByProjectId" | 950 | | "threadIdsByProjectId" |
| 922 | | "threadShellById" | 951 | | "threadShellById" |
| ... | @@ -989,11 +1018,48 @@ function buildThreadState( | ... | @@ -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 | const projects = readModel.projects | 1057 | const projects = readModel.projects |
| 994 | .filter((project) => project.deletedAt === null) | 1058 | .filter((project) => project.deletedAt === null) |
| 995 | .map(mapProject); | 1059 | .map((project) => mapProject(project, environmentId)); |
| 996 | const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); | 1060 | const threads = readModel.threads |
| 1061 | .filter((thread) => thread.deletedAt === null) | ||
| 1062 | .map((thread) => mapThread(thread, environmentId)); | ||
| 997 | return { | 1063 | return { |
| 998 | ...state, | 1064 | ...state, |
| 999 | ...buildProjectState(projects), | 1065 | ...buildProjectState(projects), |
| ... | @@ -1002,19 +1068,43 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea | ... | @@ -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 | switch (event.type) { | 1092 | switch (event.type) { |
| 1007 | case "project.created": { | 1093 | case "project.created": { |
| 1008 | const nextProject = mapProject({ | 1094 | const nextProject = mapProject( |
| 1009 | id: event.payload.projectId, | 1095 | { |
| 1010 | title: event.payload.title, | 1096 | id: event.payload.projectId, |
| 1011 | workspaceRoot: event.payload.workspaceRoot, | 1097 | title: event.payload.title, |
| 1012 | defaultModelSelection: event.payload.defaultModelSelection, | 1098 | workspaceRoot: event.payload.workspaceRoot, |
| 1013 | scripts: event.payload.scripts, | 1099 | repositoryIdentity: event.payload.repositoryIdentity ?? null, |
| 1014 | createdAt: event.payload.createdAt, | 1100 | defaultModelSelection: event.payload.defaultModelSelection, |
| 1015 | updatedAt: event.payload.updatedAt, | 1101 | scripts: event.payload.scripts, |
| 1016 | deletedAt: null, | 1102 | createdAt: event.payload.createdAt, |
| 1017 | }); | 1103 | updatedAt: event.payload.updatedAt, |
| 1104 | deletedAt: null, | ||
| 1105 | }, | ||
| 1106 | environmentId, | ||
| 1107 | ); | ||
| 1018 | const existingProjectId = | 1108 | const existingProjectId = |
| 1019 | state.projectIds.find( | 1109 | state.projectIds.find( |
| 1020 | (projectId) => | 1110 | (projectId) => |
| ... | @@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 1060 | ...project, | 1150 | ...project, |
| 1061 | ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), | 1151 | ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), |
| 1062 | ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), | 1152 | ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), |
| 1153 | ...(event.payload.repositoryIdentity !== undefined | ||
| 1154 | ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } | ||
| 1155 | : {}), | ||
| 1063 | ...(event.payload.defaultModelSelection !== undefined | 1156 | ...(event.payload.defaultModelSelection !== undefined |
| 1064 | ? { | 1157 | ? { |
| 1065 | defaultModelSelection: event.payload.defaultModelSelection | 1158 | defaultModelSelection: event.payload.defaultModelSelection |
| ... | @@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 1095 | 1188 | ||
| 1096 | case "thread.created": { | 1189 | case "thread.created": { |
| 1097 | const previousThread = getThread(state, event.payload.threadId); | 1190 | const previousThread = getThread(state, event.payload.threadId); |
| 1098 | const nextThread = mapThread({ | 1191 | const nextThread = mapThread( |
| 1099 | id: event.payload.threadId, | 1192 | { |
| 1100 | projectId: event.payload.projectId, | 1193 | id: event.payload.threadId, |
| 1101 | title: event.payload.title, | 1194 | projectId: event.payload.projectId, |
| 1102 | modelSelection: event.payload.modelSelection, | 1195 | title: event.payload.title, |
| 1103 | runtimeMode: event.payload.runtimeMode, | 1196 | modelSelection: event.payload.modelSelection, |
| 1104 | interactionMode: event.payload.interactionMode, | 1197 | runtimeMode: event.payload.runtimeMode, |
| 1105 | branch: event.payload.branch, | 1198 | interactionMode: event.payload.interactionMode, |
| 1106 | worktreePath: event.payload.worktreePath, | 1199 | branch: event.payload.branch, |
| 1107 | latestTurn: null, | 1200 | worktreePath: event.payload.worktreePath, |
| 1108 | createdAt: event.payload.createdAt, | 1201 | latestTurn: null, |
| 1109 | updatedAt: event.payload.updatedAt, | 1202 | createdAt: event.payload.createdAt, |
| 1110 | archivedAt: null, | 1203 | updatedAt: event.payload.updatedAt, |
| 1111 | deletedAt: null, | 1204 | archivedAt: null, |
| 1112 | messages: [], | 1205 | deletedAt: null, |
| 1113 | proposedPlans: [], | 1206 | messages: [], |
| 1114 | activities: [], | 1207 | proposedPlans: [], |
| 1115 | checkpoints: [], | 1208 | activities: [], |
| 1116 | session: null, | 1209 | checkpoints: [], |
| 1117 | }); | 1210 | session: null, |
| 1211 | }, | ||
| 1212 | environmentId, | ||
| 1213 | ); | ||
| 1118 | return writeThreadState(state, nextThread, previousThread); | 1214 | return writeThreadState(state, nextThread, previousThread); |
| 1119 | } | 1215 | } |
| 1120 | 1216 | ||
| ... | @@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve | ... | @@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve |
| 1481 | export function applyOrchestrationEvents( | 1577 | export function applyOrchestrationEvents( |
| 1482 | state: AppState, | 1578 | state: AppState, |
| 1483 | events: ReadonlyArray<OrchestrationEvent>, | 1579 | events: ReadonlyArray<OrchestrationEvent>, |
| 1580 | environmentId: EnvironmentId, | ||
| 1484 | ): AppState { | 1581 | ): AppState { |
| 1485 | if (events.length === 0) { | 1582 | if (events.length === 0) { |
| 1486 | return state; | 1583 | return state; |
| 1487 | } | 1584 | } |
| 1488 | return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); | 1585 | const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); |
| 1489 | } | 1586 | const nextEnvironmentState = events.reduce( |
| 1490 | 1587 | (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), | |
| 1491 | export const selectProjects = (state: AppState): Project[] => getProjects(state); | 1588 | currentEnvironmentState, |
| 1492 | export const selectThreads = (state: AppState): Thread[] => getThreads(state); | 1589 | ); |
| 1493 | export const selectProjectById = | 1590 | return commitEnvironmentState(state, environmentId, nextEnvironmentState); |
| 1494 | (projectId: Project["id"] | null | undefined) => | 1591 | } |
| 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; | ||
| 1509 | 1592 | ||
| 1510 | export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { | 1593 | function getEnvironmentEntries( |
| 1511 | return updateThreadState(state, threadId, (thread) => { | 1594 | state: AppState, |
| 1512 | if (thread.error === error) return thread; | 1595 | ): ReadonlyArray<readonly [EnvironmentId, EnvironmentState]> { |
| 1513 | return { ...thread, error }; | 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 | export function setThreadBranch( | 1743 | export function setThreadBranch( |
| 1518 | state: AppState, | 1744 | state: AppState, |
| 1519 | threadId: ThreadId, | 1745 | threadId: ThreadId, |
| 1520 | branch: string | null, | 1746 | branch: string | null, |
| 1521 | worktreePath: string | null, | 1747 | worktreePath: string | null, |
| 1522 | ): AppState { | 1748 | ): AppState { |
| 1523 | return updateThreadState(state, threadId, (thread) => { | 1749 | if (state.activeEnvironmentId === null) { |
| 1524 | if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; | 1750 | return state; |
| 1525 | const cwdChanged = thread.worktreePath !== worktreePath; | 1751 | } |
| 1526 | return { | 1752 | |
| 1527 | ...thread, | 1753 | const nextEnvironmentState = updateThreadState( |
| 1528 | branch, | 1754 | getStoredEnvironmentState(state, state.activeEnvironmentId), |
| 1529 | worktreePath, | 1755 | threadId, |
| 1530 | ...(cwdChanged ? { session: null } : {}), | 1756 | (thread) => { |
| 1531 | }; | 1757 | if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; |
| 1532 | }); | 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 | interface AppStore extends AppState { | 1770 | interface AppStore extends AppState { |
| 1536 | syncServerReadModel: (readModel: OrchestrationReadModel) => void; | 1771 | setActiveEnvironmentId: (environmentId: EnvironmentId) => void; |
| 1537 | applyOrchestrationEvent: (event: OrchestrationEvent) => void; | 1772 | syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; |
| 1538 | applyOrchestrationEvents: (events: ReadonlyArray<OrchestrationEvent>) => void; | 1773 | applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; |
| 1774 | applyOrchestrationEvents: ( | ||
| 1775 | events: ReadonlyArray<OrchestrationEvent>, | ||
| 1776 | environmentId: EnvironmentId, | ||
| 1777 | ) => void; | ||
| 1539 | setError: (threadId: ThreadId, error: string | null) => void; | 1778 | setError: (threadId: ThreadId, error: string | null) => void; |
| 1540 | setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; | 1779 | setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; |
| 1541 | } | 1780 | } |
| 1542 | 1781 | ||
| 1543 | export const useStore = create<AppStore>((set) => ({ | 1782 | export const useStore = create<AppStore>((set) => ({ |
| 1544 | ...initialState, | 1783 | ...initialState, |
| 1545 | syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), | 1784 | setActiveEnvironmentId: (environmentId) => |
| 1546 | applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), | 1785 | set((state) => setActiveEnvironmentId(state, environmentId)), |
| 1547 | applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), | 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 | setError: (threadId, error) => set((state) => setError(state, threadId, error)), | 1792 | setError: (threadId, error) => set((state) => setError(state, threadId, error)), |
| 1549 | setThreadBranch: (threadId, branch, worktreePath) => | 1793 | setThreadBranch: (threadId, branch, worktreePath) => |
| 1550 | set((state) => setThreadBranch(state, threadId, branch, worktreePath)), | 1794 | set((state) => setThreadBranch(state, threadId, branch, worktreePath)), |
apps/web/src/storeSelectors.ts+68-27| ... | @@ -1,5 +1,11 @@ | ... | @@ -1,5 +1,11 @@ |
| 1 | import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { type AppState } from "./store"; | 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 | import { | 9 | import { |
| 4 | type ChatMessage, | 10 | type ChatMessage, |
| 5 | type Project, | 11 | type Project, |
| ... | @@ -30,54 +36,61 @@ function collectByIds<TKey extends string, TValue>( | ... | @@ -30,54 +36,61 @@ function collectByIds<TKey extends string, TValue>( |
| 30 | }); | 36 | }); |
| 31 | } | 37 | } |
| 32 | 38 | ||
| 33 | export function createProjectSelector( | 39 | export function createProjectSelectorByRef( |
| 34 | projectId: ProjectId | null | undefined, | 40 | ref: ScopedProjectRef | null | undefined, |
| 35 | ): (state: AppState) => Project | undefined { | 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( | 46 | export function createSidebarThreadSummarySelectorByRef( |
| 40 | threadId: ThreadId | null | undefined, | 47 | ref: ScopedThreadRef | null | undefined, |
| 41 | ): (state: AppState) => SidebarThreadSummary | undefined { | 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( | 55 | function createScopedThreadSelector( |
| 46 | threadId: ThreadId | null | undefined, | 56 | resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, |
| 47 | ): (state: AppState) => Thread | undefined { | 57 | ): (state: AppState) => Thread | undefined { |
| 48 | let previousShell: AppState["threadShellById"][ThreadId] | undefined; | 58 | let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; |
| 49 | let previousSession: ThreadSession | null | undefined; | 59 | let previousSession: ThreadSession | null | undefined; |
| 50 | let previousTurnState: ThreadTurnState | undefined; | 60 | let previousTurnState: ThreadTurnState | undefined; |
| 51 | let previousMessageIds: MessageId[] | undefined; | 61 | let previousMessageIds: MessageId[] | undefined; |
| 52 | let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined; | 62 | let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined; |
| 53 | let previousActivityIds: string[] | undefined; | 63 | let previousActivityIds: string[] | undefined; |
| 54 | let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined; | 64 | let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined; |
| 55 | let previousProposedPlanIds: string[] | undefined; | 65 | let previousProposedPlanIds: string[] | undefined; |
| 56 | let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined; | 66 | let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined; |
| 57 | let previousTurnDiffIds: TurnId[] | undefined; | 67 | let previousTurnDiffIds: TurnId[] | undefined; |
| 58 | let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined; | 68 | let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined; |
| 59 | let previousThread: Thread | undefined; | 69 | let previousThread: Thread | undefined; |
| 60 | 70 | ||
| 61 | return (state) => { | 71 | return (state) => { |
| 62 | if (!threadId) { | 72 | const ref = resolveRef(state); |
| 73 | if (!ref) { | ||
| 63 | return undefined; | 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 | if (!shell) { | 80 | if (!shell) { |
| 68 | return undefined; | 81 | return undefined; |
| 69 | } | 82 | } |
| 70 | 83 | ||
| 71 | const session = state.threadSessionById[threadId] ?? null; | 84 | const session = environmentState.threadSessionById[threadId] ?? null; |
| 72 | const turnState = state.threadTurnStateById[threadId]; | 85 | const turnState = environmentState.threadTurnStateById[threadId]; |
| 73 | const messageIds = state.messageIdsByThreadId[threadId]; | 86 | const messageIds = environmentState.messageIdsByThreadId[threadId]; |
| 74 | const messageById = state.messageByThreadId[threadId]; | 87 | const messageById = environmentState.messageByThreadId[threadId]; |
| 75 | const activityIds = state.activityIdsByThreadId[threadId]; | 88 | const activityIds = environmentState.activityIdsByThreadId[threadId]; |
| 76 | const activityById = state.activityByThreadId[threadId]; | 89 | const activityById = environmentState.activityByThreadId[threadId]; |
| 77 | const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId]; | 90 | const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; |
| 78 | const proposedPlanById = state.proposedPlanByThreadId[threadId]; | 91 | const proposedPlanById = environmentState.proposedPlanByThreadId[threadId]; |
| 79 | const turnDiffIds = state.turnDiffIdsByThreadId[threadId]; | 92 | const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId]; |
| 80 | const turnDiffById = state.turnDiffSummaryByThreadId[threadId]; | 93 | const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId]; |
| 81 | 94 | ||
| 82 | if ( | 95 | if ( |
| 83 | previousThread && | 96 | previousThread && |
| ... | @@ -144,3 +157,31 @@ export function createThreadSelector( | ... | @@ -144,3 +157,31 @@ export function createThreadSelector( |
| 144 | return previousThread; | 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,13 +1,17 @@ |
| 1 | import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; | ||
| 1 | import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; | 2 | import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; |
| 2 | import { beforeEach, describe, expect, it } from "vitest"; | 3 | import { beforeEach, describe, expect, it } from "vitest"; |
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 6 | migratePersistedTerminalStateStoreState, | ||
| 5 | selectTerminalEventEntries, | 7 | selectTerminalEventEntries, |
| 6 | selectThreadTerminalState, | 8 | selectThreadTerminalState, |
| 7 | useTerminalStateStore, | 9 | useTerminalStateStore, |
| 8 | } from "./terminalStateStore"; | 10 | } from "./terminalStateStore"; |
| 9 | 11 | ||
| 10 | const THREAD_ID = ThreadId.makeUnsafe("thread-1"); | 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 | function makeTerminalEvent( | 16 | function makeTerminalEvent( |
| 13 | type: TerminalEvent["type"], | 17 | type: TerminalEvent["type"], |
| ... | @@ -56,8 +60,8 @@ describe("terminalStateStore actions", () => { | ... | @@ -56,8 +60,8 @@ describe("terminalStateStore actions", () => { |
| 56 | beforeEach(() => { | 60 | beforeEach(() => { |
| 57 | useTerminalStateStore.persist.clearStorage(); | 61 | useTerminalStateStore.persist.clearStorage(); |
| 58 | useTerminalStateStore.setState({ | 62 | useTerminalStateStore.setState({ |
| 59 | terminalStateByThreadId: {}, | 63 | terminalStateByThreadKey: {}, |
| 60 | terminalLaunchContextByThreadId: {}, | 64 | terminalLaunchContextByThreadKey: {}, |
| 61 | terminalEventEntriesByKey: {}, | 65 | terminalEventEntriesByKey: {}, |
| 62 | nextTerminalEventId: 1, | 66 | nextTerminalEventId: 1, |
| 63 | }); | 67 | }); |
| ... | @@ -65,8 +69,8 @@ describe("terminalStateStore actions", () => { | ... | @@ -65,8 +69,8 @@ describe("terminalStateStore actions", () => { |
| 65 | 69 | ||
| 66 | it("returns a closed default terminal state for unknown threads", () => { | 70 | it("returns a closed default terminal state for unknown threads", () => { |
| 67 | const terminalState = selectThreadTerminalState( | 71 | const terminalState = selectThreadTerminalState( |
| 68 | useTerminalStateStore.getState().terminalStateByThreadId, | 72 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 69 | THREAD_ID, | 73 | THREAD_REF, |
| 70 | ); | 74 | ); |
| 71 | expect(terminalState).toEqual({ | 75 | expect(terminalState).toEqual({ |
| 72 | terminalOpen: false, | 76 | terminalOpen: false, |
| ... | @@ -81,12 +85,12 @@ describe("terminalStateStore actions", () => { | ... | @@ -81,12 +85,12 @@ describe("terminalStateStore actions", () => { |
| 81 | 85 | ||
| 82 | it("opens and splits terminals into the active group", () => { | 86 | it("opens and splits terminals into the active group", () => { |
| 83 | const store = useTerminalStateStore.getState(); | 87 | const store = useTerminalStateStore.getState(); |
| 84 | store.setTerminalOpen(THREAD_ID, true); | 88 | store.setTerminalOpen(THREAD_REF, true); |
| 85 | store.splitTerminal(THREAD_ID, "terminal-2"); | 89 | store.splitTerminal(THREAD_REF, "terminal-2"); |
| 86 | 90 | ||
| 87 | const terminalState = selectThreadTerminalState( | 91 | const terminalState = selectThreadTerminalState( |
| 88 | useTerminalStateStore.getState().terminalStateByThreadId, | 92 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 89 | THREAD_ID, | 93 | THREAD_REF, |
| 90 | ); | 94 | ); |
| 91 | expect(terminalState.terminalOpen).toBe(true); | 95 | expect(terminalState.terminalOpen).toBe(true); |
| 92 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | 96 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| ... | @@ -98,14 +102,14 @@ describe("terminalStateStore actions", () => { | ... | @@ -98,14 +102,14 @@ describe("terminalStateStore actions", () => { |
| 98 | 102 | ||
| 99 | it("caps splits at four terminals per group", () => { | 103 | it("caps splits at four terminals per group", () => { |
| 100 | const store = useTerminalStateStore.getState(); | 104 | const store = useTerminalStateStore.getState(); |
| 101 | store.splitTerminal(THREAD_ID, "terminal-2"); | 105 | store.splitTerminal(THREAD_REF, "terminal-2"); |
| 102 | store.splitTerminal(THREAD_ID, "terminal-3"); | 106 | store.splitTerminal(THREAD_REF, "terminal-3"); |
| 103 | store.splitTerminal(THREAD_ID, "terminal-4"); | 107 | store.splitTerminal(THREAD_REF, "terminal-4"); |
| 104 | store.splitTerminal(THREAD_ID, "terminal-5"); | 108 | store.splitTerminal(THREAD_REF, "terminal-5"); |
| 105 | 109 | ||
| 106 | const terminalState = selectThreadTerminalState( | 110 | const terminalState = selectThreadTerminalState( |
| 107 | useTerminalStateStore.getState().terminalStateByThreadId, | 111 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 108 | THREAD_ID, | 112 | THREAD_REF, |
| 109 | ); | 113 | ); |
| 110 | expect(terminalState.terminalIds).toEqual([ | 114 | expect(terminalState.terminalIds).toEqual([ |
| 111 | "default", | 115 | "default", |
| ... | @@ -119,11 +123,11 @@ describe("terminalStateStore actions", () => { | ... | @@ -119,11 +123,11 @@ describe("terminalStateStore actions", () => { |
| 119 | }); | 123 | }); |
| 120 | 124 | ||
| 121 | it("creates new terminals in a separate group", () => { | 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 | const terminalState = selectThreadTerminalState( | 128 | const terminalState = selectThreadTerminalState( |
| 125 | useTerminalStateStore.getState().terminalStateByThreadId, | 129 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 126 | THREAD_ID, | 130 | THREAD_REF, |
| 127 | ); | 131 | ); |
| 128 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | 132 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| 129 | expect(terminalState.activeTerminalId).toBe("terminal-2"); | 133 | expect(terminalState.activeTerminalId).toBe("terminal-2"); |
| ... | @@ -136,11 +140,11 @@ describe("terminalStateStore actions", () => { | ... | @@ -136,11 +140,11 @@ describe("terminalStateStore actions", () => { |
| 136 | 140 | ||
| 137 | it("ensures unknown server terminals are registered, opened, and activated", () => { | 141 | it("ensures unknown server terminals are registered, opened, and activated", () => { |
| 138 | const store = useTerminalStateStore.getState(); | 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 | const terminalState = selectThreadTerminalState( | 145 | const terminalState = selectThreadTerminalState( |
| 142 | useTerminalStateStore.getState().terminalStateByThreadId, | 146 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 143 | THREAD_ID, | 147 | THREAD_REF, |
| 144 | ); | 148 | ); |
| 145 | expect(terminalState.terminalOpen).toBe(true); | 149 | expect(terminalState.terminalOpen).toBe(true); |
| 146 | expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); | 150 | expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); |
| ... | @@ -151,69 +155,111 @@ describe("terminalStateStore actions", () => { | ... | @@ -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 | const store = useTerminalStateStore.getState(); | 159 | const store = useTerminalStateStore.getState(); |
| 156 | store.splitTerminal(THREAD_ID, "terminal-2"); | 160 | store.setTerminalOpen(THREAD_REF, true); |
| 157 | store.splitTerminal(THREAD_ID, "terminal-3"); | 161 | store.newTerminal(OTHER_THREAD_REF, "env-b-terminal"); |
| 158 | store.splitTerminal(THREAD_ID, "terminal-4"); | ||
| 159 | store.newTerminal(THREAD_ID, "terminal-5"); | ||
| 160 | store.newTerminal(THREAD_ID, "terminal-6"); | ||
| 161 | 162 | ||
| 162 | const terminalState = selectThreadTerminalState( | 163 | expect( |
| 163 | useTerminalStateStore.getState().terminalStateByThreadId, | 164 | selectThreadTerminalState( |
| 164 | THREAD_ID, | 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([ | 203 | |
| 167 | "default", | 204 | expect(migrated).toEqual({ |
| 168 | "terminal-2", | 205 | terminalStateByThreadKey: { |
| 169 | "terminal-3", | 206 | [scopedThreadKey(THREAD_REF)]: { |
| 170 | "terminal-4", | 207 | terminalOpen: true, |
| 171 | "terminal-5", | 208 | terminalHeight: 320, |
| 172 | "terminal-6", | 209 | terminalIds: ["default"], |
| 173 | ]); | 210 | runningTerminalIds: [], |
| 174 | expect(terminalState.terminalGroups).toEqual([ | 211 | activeTerminalId: "default", |
| 175 | { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, | 212 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], |
| 176 | { id: "group-terminal-5", terminalIds: ["terminal-5"] }, | 213 | activeTerminalGroupId: "group-default", |
| 177 | { id: "group-terminal-6", terminalIds: ["terminal-6"] }, | 214 | }, |
| 178 | ]); | 215 | }, |
| 216 | }); | ||
| 179 | }); | 217 | }); |
| 180 | 218 | ||
| 181 | it("tracks and clears terminal subprocess activity", () => { | 219 | it("tracks and clears terminal subprocess activity", () => { |
| 182 | const store = useTerminalStateStore.getState(); | 220 | const store = useTerminalStateStore.getState(); |
| 183 | store.splitTerminal(THREAD_ID, "terminal-2"); | 221 | store.splitTerminal(THREAD_REF, "terminal-2"); |
| 184 | store.setTerminalActivity(THREAD_ID, "terminal-2", true); | 222 | store.setTerminalActivity(THREAD_REF, "terminal-2", true); |
| 185 | expect( | 223 | expect( |
| 186 | selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) | 224 | selectThreadTerminalState( |
| 187 | .runningTerminalIds, | 225 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 226 | THREAD_REF, | ||
| 227 | ).runningTerminalIds, | ||
| 188 | ).toEqual(["terminal-2"]); | 228 | ).toEqual(["terminal-2"]); |
| 189 | 229 | ||
| 190 | store.setTerminalActivity(THREAD_ID, "terminal-2", false); | 230 | store.setTerminalActivity(THREAD_REF, "terminal-2", false); |
| 191 | expect( | 231 | expect( |
| 192 | selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) | 232 | selectThreadTerminalState( |
| 193 | .runningTerminalIds, | 233 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 234 | THREAD_REF, | ||
| 235 | ).runningTerminalIds, | ||
| 194 | ).toEqual([]); | 236 | ).toEqual([]); |
| 195 | }); | 237 | }); |
| 196 | 238 | ||
| 197 | it("resets to default and clears persisted entry when closing the last terminal", () => { | 239 | it("resets to default and clears persisted entry when closing the last terminal", () => { |
| 198 | const store = useTerminalStateStore.getState(); | 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 | expect( | 243 | expect( |
| 203 | selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) | 244 | useTerminalStateStore.getState().terminalStateByThreadKey[scopedThreadKey(THREAD_REF)], |
| 204 | .terminalIds, | 245 | ).toBeUndefined(); |
| 246 | expect( | ||
| 247 | selectThreadTerminalState( | ||
| 248 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 249 | THREAD_REF, | ||
| 250 | ).terminalIds, | ||
| 205 | ).toEqual(["default"]); | 251 | ).toEqual(["default"]); |
| 206 | }); | 252 | }); |
| 207 | 253 | ||
| 208 | it("keeps a valid active terminal after closing an active split terminal", () => { | 254 | it("keeps a valid active terminal after closing an active split terminal", () => { |
| 209 | const store = useTerminalStateStore.getState(); | 255 | const store = useTerminalStateStore.getState(); |
| 210 | store.splitTerminal(THREAD_ID, "terminal-2"); | 256 | store.splitTerminal(THREAD_REF, "terminal-2"); |
| 211 | store.splitTerminal(THREAD_ID, "terminal-3"); | 257 | store.splitTerminal(THREAD_REF, "terminal-3"); |
| 212 | store.closeTerminal(THREAD_ID, "terminal-3"); | 258 | store.closeTerminal(THREAD_REF, "terminal-3"); |
| 213 | 259 | ||
| 214 | const terminalState = selectThreadTerminalState( | 260 | const terminalState = selectThreadTerminalState( |
| 215 | useTerminalStateStore.getState().terminalStateByThreadId, | 261 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 216 | THREAD_ID, | 262 | THREAD_REF, |
| 217 | ); | 263 | ); |
| 218 | expect(terminalState.activeTerminalId).toBe("terminal-2"); | 264 | expect(terminalState.activeTerminalId).toBe("terminal-2"); |
| 219 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | 265 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| ... | @@ -224,12 +270,12 @@ describe("terminalStateStore actions", () => { | ... | @@ -224,12 +270,12 @@ describe("terminalStateStore actions", () => { |
| 224 | 270 | ||
| 225 | it("buffers terminal events outside persisted terminal UI state", () => { | 271 | it("buffers terminal events outside persisted terminal UI state", () => { |
| 226 | const store = useTerminalStateStore.getState(); | 272 | const store = useTerminalStateStore.getState(); |
| 227 | store.recordTerminalEvent(makeTerminalEvent("output")); | 273 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); |
| 228 | store.recordTerminalEvent(makeTerminalEvent("activity")); | 274 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("activity")); |
| 229 | 275 | ||
| 230 | const entries = selectTerminalEventEntries( | 276 | const entries = selectTerminalEventEntries( |
| 231 | useTerminalStateStore.getState().terminalEventEntriesByKey, | 277 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 232 | THREAD_ID, | 278 | THREAD_REF, |
| 233 | "default", | 279 | "default", |
| 234 | ); | 280 | ); |
| 235 | 281 | ||
| ... | @@ -241,6 +287,7 @@ describe("terminalStateStore actions", () => { | ... | @@ -241,6 +287,7 @@ describe("terminalStateStore actions", () => { |
| 241 | it("applies started terminal events to terminal state, launch context, and event buffer", () => { | 287 | it("applies started terminal events to terminal state, launch context, and event buffer", () => { |
| 242 | const store = useTerminalStateStore.getState(); | 288 | const store = useTerminalStateStore.getState(); |
| 243 | store.applyTerminalEvent( | 289 | store.applyTerminalEvent( |
| 290 | THREAD_REF, | ||
| 244 | makeTerminalEvent("started", { | 291 | makeTerminalEvent("started", { |
| 245 | terminalId: "setup-bootstrap", | 292 | terminalId: "setup-bootstrap", |
| 246 | snapshot: { | 293 | snapshot: { |
| ... | @@ -259,19 +306,23 @@ describe("terminalStateStore actions", () => { | ... | @@ -259,19 +306,23 @@ describe("terminalStateStore actions", () => { |
| 259 | ); | 306 | ); |
| 260 | 307 | ||
| 261 | const terminalState = selectThreadTerminalState( | 308 | const terminalState = selectThreadTerminalState( |
| 262 | useTerminalStateStore.getState().terminalStateByThreadId, | 309 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 263 | THREAD_ID, | 310 | THREAD_REF, |
| 264 | ); | 311 | ); |
| 265 | const entries = selectTerminalEventEntries( | 312 | const entries = selectTerminalEventEntries( |
| 266 | useTerminalStateStore.getState().terminalEventEntriesByKey, | 313 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 267 | THREAD_ID, | 314 | THREAD_REF, |
| 268 | "setup-bootstrap", | 315 | "setup-bootstrap", |
| 269 | ); | 316 | ); |
| 270 | 317 | ||
| 271 | expect(terminalState.terminalOpen).toBe(true); | 318 | expect(terminalState.terminalOpen).toBe(true); |
| 272 | expect(terminalState.activeTerminalId).toBe("setup-bootstrap"); | 319 | expect(terminalState.activeTerminalId).toBe("setup-bootstrap"); |
| 273 | expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]); | 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 | cwd: "/tmp/worktree", | 326 | cwd: "/tmp/worktree", |
| 276 | worktreePath: "/tmp/worktree", | 327 | worktreePath: "/tmp/worktree", |
| 277 | }); | 328 | }); |
| ... | @@ -281,20 +332,24 @@ describe("terminalStateStore actions", () => { | ... | @@ -281,20 +332,24 @@ describe("terminalStateStore actions", () => { |
| 281 | 332 | ||
| 282 | it("applies activity and exited terminal events to subprocess state while buffering events", () => { | 333 | it("applies activity and exited terminal events to subprocess state while buffering events", () => { |
| 283 | const store = useTerminalStateStore.getState(); | 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 | store.applyTerminalEvent( | 337 | store.applyTerminalEvent( |
| 338 | THREAD_REF, | ||
| 287 | makeTerminalEvent("activity", { | 339 | makeTerminalEvent("activity", { |
| 288 | terminalId: "terminal-2", | 340 | terminalId: "terminal-2", |
| 289 | hasRunningSubprocess: true, | 341 | hasRunningSubprocess: true, |
| 290 | }), | 342 | }), |
| 291 | ); | 343 | ); |
| 292 | expect( | 344 | expect( |
| 293 | selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) | 345 | selectThreadTerminalState( |
| 294 | .runningTerminalIds, | 346 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 347 | THREAD_REF, | ||
| 348 | ).runningTerminalIds, | ||
| 295 | ).toEqual(["terminal-2"]); | 349 | ).toEqual(["terminal-2"]); |
| 296 | 350 | ||
| 297 | store.applyTerminalEvent( | 351 | store.applyTerminalEvent( |
| 352 | THREAD_REF, | ||
| 298 | makeTerminalEvent("exited", { | 353 | makeTerminalEvent("exited", { |
| 299 | terminalId: "terminal-2", | 354 | terminalId: "terminal-2", |
| 300 | exitCode: 0, | 355 | exitCode: 0, |
| ... | @@ -303,12 +358,12 @@ describe("terminalStateStore actions", () => { | ... | @@ -303,12 +358,12 @@ describe("terminalStateStore actions", () => { |
| 303 | ); | 358 | ); |
| 304 | 359 | ||
| 305 | const terminalState = selectThreadTerminalState( | 360 | const terminalState = selectThreadTerminalState( |
| 306 | useTerminalStateStore.getState().terminalStateByThreadId, | 361 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 307 | THREAD_ID, | 362 | THREAD_REF, |
| 308 | ); | 363 | ); |
| 309 | const entries = selectTerminalEventEntries( | 364 | const entries = selectTerminalEventEntries( |
| 310 | useTerminalStateStore.getState().terminalEventEntriesByKey, | 365 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 311 | THREAD_ID, | 366 | THREAD_REF, |
| 312 | "terminal-2", | 367 | "terminal-2", |
| 313 | ); | 368 | ); |
| 314 | 369 | ||
| ... | @@ -318,12 +373,12 @@ describe("terminalStateStore actions", () => { | ... | @@ -318,12 +373,12 @@ describe("terminalStateStore actions", () => { |
| 318 | 373 | ||
| 319 | it("clears buffered terminal events when a thread terminal state is removed", () => { | 374 | it("clears buffered terminal events when a thread terminal state is removed", () => { |
| 320 | const store = useTerminalStateStore.getState(); | 375 | const store = useTerminalStateStore.getState(); |
| 321 | store.recordTerminalEvent(makeTerminalEvent("output")); | 376 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); |
| 322 | store.removeTerminalState(THREAD_ID); | 377 | store.removeTerminalState(THREAD_REF); |
| 323 | 378 | ||
| 324 | const entries = selectTerminalEventEntries( | 379 | const entries = selectTerminalEventEntries( |
| 325 | useTerminalStateStore.getState().terminalEventEntriesByKey, | 380 | useTerminalStateStore.getState().terminalEventEntriesByKey, |
| 326 | THREAD_ID, | 381 | THREAD_REF, |
| 327 | "default", | 382 | "default", |
| 328 | ); | 383 | ); |
| 329 | 384 | ||
| ... | @@ -334,7 +389,7 @@ describe("terminalStateStore actions", () => { | ... | @@ -334,7 +389,7 @@ describe("terminalStateStore actions", () => { |
| 334 | const store = useTerminalStateStore.getState(); | 389 | const store = useTerminalStateStore.getState(); |
| 335 | const before = useTerminalStateStore.getState(); | 390 | const before = useTerminalStateStore.getState(); |
| 336 | 391 | ||
| 337 | store.clearTerminalState(THREAD_ID); | 392 | store.clearTerminalState(THREAD_REF); |
| 338 | 393 | ||
| 339 | expect(useTerminalStateStore.getState()).toBe(before); | 394 | expect(useTerminalStateStore.getState()).toBe(before); |
| 340 | }); | 395 | }); |
apps/web/src/terminalStateStore.ts+165-127| ... | @@ -1,11 +1,12 @@ | ... | @@ -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 | * Terminal transition helpers are intentionally private to keep the public | 4 | * Terminal transition helpers are intentionally private to keep the public |
| 5 | * API constrained to store actions/selectors. | 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 | import { create } from "zustand"; | 10 | import { create } from "zustand"; |
| 10 | import { createJSONStorage, persist } from "zustand/middleware"; | 11 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 11 | import { resolveStorage } from "./lib/storage"; | 12 | import { resolveStorage } from "./lib/storage"; |
| ... | @@ -41,6 +42,26 @@ const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; | ... | @@ -41,6 +42,26 @@ const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; |
| 41 | const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = []; | 42 | const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = []; |
| 42 | const MAX_TERMINAL_EVENT_BUFFER = 200; | 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 | function createTerminalStateStorage() { | 65 | function createTerminalStateStorage() { |
| 45 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); | 66 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); |
| 46 | } | 67 | } |
| ... | @@ -240,8 +261,12 @@ function isValidTerminalId(terminalId: string): boolean { | ... | @@ -240,8 +261,12 @@ function isValidTerminalId(terminalId: string): boolean { |
| 240 | return terminalId.trim().length > 0; | 261 | return terminalId.trim().length > 0; |
| 241 | } | 262 | } |
| 242 | 263 | ||
| 243 | function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string { | 264 | function terminalThreadKey(threadRef: ScopedThreadRef): string { |
| 244 | return `${threadId}\u0000${terminalId}`; | 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 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { | 272 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { |
| ... | @@ -254,9 +279,10 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ | ... | @@ -254,9 +279,10 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ |
| 254 | function appendTerminalEventEntry( | 279 | function appendTerminalEventEntry( |
| 255 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, | 280 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, |
| 256 | nextTerminalEventId: number, | 281 | nextTerminalEventId: number, |
| 282 | threadRef: ScopedThreadRef, | ||
| 257 | event: TerminalEvent, | 283 | event: TerminalEvent, |
| 258 | ) { | 284 | ) { |
| 259 | const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId); | 285 | const key = terminalEventBufferKey(threadRef, event.terminalId); |
| 260 | const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; | 286 | const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; |
| 261 | const nextEntry: TerminalEventEntry = { | 287 | const nextEntry: TerminalEventEntry = { |
| 262 | id: nextTerminalEventId, | 288 | id: nextTerminalEventId, |
| ... | @@ -484,125 +510,129 @@ function setThreadTerminalActivity( | ... | @@ -484,125 +510,129 @@ function setThreadTerminalActivity( |
| 484 | } | 510 | } |
| 485 | 511 | ||
| 486 | export function selectThreadTerminalState( | 512 | export function selectThreadTerminalState( |
| 487 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>, | 513 | terminalStateByThreadKey: Record<string, ThreadTerminalState>, |
| 488 | threadId: ThreadId, | 514 | threadRef: ScopedThreadRef | null | undefined, |
| 489 | ): ThreadTerminalState { | 515 | ): ThreadTerminalState { |
| 490 | if (threadId.length === 0) { | 516 | if (!threadRef || threadRef.threadId.length === 0) { |
| 491 | return getDefaultThreadTerminalState(); | 517 | return getDefaultThreadTerminalState(); |
| 492 | } | 518 | } |
| 493 | return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState(); | 519 | return terminalStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalState(); |
| 494 | } | 520 | } |
| 495 | 521 | ||
| 496 | function updateTerminalStateByThreadId( | 522 | function updateTerminalStateByThreadKey( |
| 497 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>, | 523 | terminalStateByThreadKey: Record<string, ThreadTerminalState>, |
| 498 | threadId: ThreadId, | 524 | threadRef: ScopedThreadRef, |
| 499 | updater: (state: ThreadTerminalState) => ThreadTerminalState, | 525 | updater: (state: ThreadTerminalState) => ThreadTerminalState, |
| 500 | ): Record<ThreadId, ThreadTerminalState> { | 526 | ): Record<string, ThreadTerminalState> { |
| 501 | if (threadId.length === 0) { | 527 | if (threadRef.threadId.length === 0) { |
| 502 | return terminalStateByThreadId; | 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 | const next = updater(current); | 533 | const next = updater(current); |
| 507 | if (next === current) { | 534 | if (next === current) { |
| 508 | return terminalStateByThreadId; | 535 | return terminalStateByThreadKey; |
| 509 | } | 536 | } |
| 510 | 537 | ||
| 511 | if (isDefaultThreadTerminalState(next)) { | 538 | if (isDefaultThreadTerminalState(next)) { |
| 512 | if (terminalStateByThreadId[threadId] === undefined) { | 539 | if (terminalStateByThreadKey[threadKey] === undefined) { |
| 513 | return terminalStateByThreadId; | 540 | return terminalStateByThreadKey; |
| 514 | } | 541 | } |
| 515 | const { [threadId]: _removed, ...rest } = terminalStateByThreadId; | 542 | const { [threadKey]: _removed, ...rest } = terminalStateByThreadKey; |
| 516 | return rest as Record<ThreadId, ThreadTerminalState>; | 543 | return rest; |
| 517 | } | 544 | } |
| 518 | 545 | ||
| 519 | return { | 546 | return { |
| 520 | ...terminalStateByThreadId, | 547 | ...terminalStateByThreadKey, |
| 521 | [threadId]: next, | 548 | [threadKey]: next, |
| 522 | }; | 549 | }; |
| 523 | } | 550 | } |
| 524 | 551 | ||
| 525 | export function selectTerminalEventEntries( | 552 | export function selectTerminalEventEntries( |
| 526 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, | 553 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, |
| 527 | threadId: ThreadId, | 554 | threadRef: ScopedThreadRef | null | undefined, |
| 528 | terminalId: string, | 555 | terminalId: string, |
| 529 | ): ReadonlyArray<TerminalEventEntry> { | 556 | ): ReadonlyArray<TerminalEventEntry> { |
| 530 | if (threadId.length === 0 || terminalId.trim().length === 0) { | 557 | if (!threadRef || threadRef.threadId.length === 0 || terminalId.trim().length === 0) { |
| 531 | return EMPTY_TERMINAL_EVENT_ENTRIES; | 558 | return EMPTY_TERMINAL_EVENT_ENTRIES; |
| 532 | } | 559 | } |
| 533 | return ( | 560 | return ( |
| 534 | terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ?? | 561 | terminalEventEntriesByKey[terminalEventBufferKey(threadRef, terminalId)] ?? |
| 535 | EMPTY_TERMINAL_EVENT_ENTRIES | 562 | EMPTY_TERMINAL_EVENT_ENTRIES |
| 536 | ); | 563 | ); |
| 537 | } | 564 | } |
| 538 | 565 | ||
| 539 | interface TerminalStateStoreState { | 566 | interface TerminalStateStoreState { |
| 540 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>; | 567 | terminalStateByThreadKey: Record<string, ThreadTerminalState>; |
| 541 | terminalLaunchContextByThreadId: Record<ThreadId, ThreadTerminalLaunchContext>; | 568 | terminalLaunchContextByThreadKey: Record<string, ThreadTerminalLaunchContext>; |
| 542 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>; | 569 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>; |
| 543 | nextTerminalEventId: number; | 570 | nextTerminalEventId: number; |
| 544 | setTerminalOpen: (threadId: ThreadId, open: boolean) => void; | 571 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; |
| 545 | setTerminalHeight: (threadId: ThreadId, height: number) => void; | 572 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; |
| 546 | splitTerminal: (threadId: ThreadId, terminalId: string) => void; | 573 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 547 | newTerminal: (threadId: ThreadId, terminalId: string) => void; | 574 | newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 548 | ensureTerminal: ( | 575 | ensureTerminal: ( |
| 549 | threadId: ThreadId, | 576 | threadRef: ScopedThreadRef, |
| 550 | terminalId: string, | 577 | terminalId: string, |
| 551 | options?: { open?: boolean; active?: boolean }, | 578 | options?: { open?: boolean; active?: boolean }, |
| 552 | ) => void; | 579 | ) => void; |
| 553 | setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; | 580 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 554 | closeTerminal: (threadId: ThreadId, terminalId: string) => void; | 581 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 555 | setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void; | 582 | setTerminalLaunchContext: ( |
| 556 | clearTerminalLaunchContext: (threadId: ThreadId) => void; | 583 | threadRef: ScopedThreadRef, |
| 584 | context: ThreadTerminalLaunchContext, | ||
| 585 | ) => void; | ||
| 586 | clearTerminalLaunchContext: (threadRef: ScopedThreadRef) => void; | ||
| 557 | setTerminalActivity: ( | 587 | setTerminalActivity: ( |
| 558 | threadId: ThreadId, | 588 | threadRef: ScopedThreadRef, |
| 559 | terminalId: string, | 589 | terminalId: string, |
| 560 | hasRunningSubprocess: boolean, | 590 | hasRunningSubprocess: boolean, |
| 561 | ) => void; | 591 | ) => void; |
| 562 | recordTerminalEvent: (event: TerminalEvent) => void; | 592 | recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; |
| 563 | applyTerminalEvent: (event: TerminalEvent) => void; | 593 | applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; |
| 564 | clearTerminalState: (threadId: ThreadId) => void; | 594 | clearTerminalState: (threadRef: ScopedThreadRef) => void; |
| 565 | removeTerminalState: (threadId: ThreadId) => void; | 595 | removeTerminalState: (threadRef: ScopedThreadRef) => void; |
| 566 | removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void; | 596 | removeOrphanedTerminalStates: (activeThreadKeys: Set<string>) => void; |
| 567 | } | 597 | } |
| 568 | 598 | ||
| 569 | export const useTerminalStateStore = create<TerminalStateStoreState>()( | 599 | export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 570 | persist( | 600 | persist( |
| 571 | (set) => { | 601 | (set) => { |
| 572 | const updateTerminal = ( | 602 | const updateTerminal = ( |
| 573 | threadId: ThreadId, | 603 | threadRef: ScopedThreadRef, |
| 574 | updater: (state: ThreadTerminalState) => ThreadTerminalState, | 604 | updater: (state: ThreadTerminalState) => ThreadTerminalState, |
| 575 | ) => { | 605 | ) => { |
| 576 | set((state) => { | 606 | set((state) => { |
| 577 | const nextTerminalStateByThreadId = updateTerminalStateByThreadId( | 607 | const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( |
| 578 | state.terminalStateByThreadId, | 608 | state.terminalStateByThreadKey, |
| 579 | threadId, | 609 | threadRef, |
| 580 | updater, | 610 | updater, |
| 581 | ); | 611 | ); |
| 582 | if (nextTerminalStateByThreadId === state.terminalStateByThreadId) { | 612 | if (nextTerminalStateByThreadKey === state.terminalStateByThreadKey) { |
| 583 | return state; | 613 | return state; |
| 584 | } | 614 | } |
| 585 | return { | 615 | return { |
| 586 | terminalStateByThreadId: nextTerminalStateByThreadId, | 616 | terminalStateByThreadKey: nextTerminalStateByThreadKey, |
| 587 | }; | 617 | }; |
| 588 | }); | 618 | }); |
| 589 | }; | 619 | }; |
| 590 | 620 | ||
| 591 | return { | 621 | return { |
| 592 | terminalStateByThreadId: {}, | 622 | terminalStateByThreadKey: {}, |
| 593 | terminalLaunchContextByThreadId: {}, | 623 | terminalLaunchContextByThreadKey: {}, |
| 594 | terminalEventEntriesByKey: {}, | 624 | terminalEventEntriesByKey: {}, |
| 595 | nextTerminalEventId: 1, | 625 | nextTerminalEventId: 1, |
| 596 | setTerminalOpen: (threadId, open) => | 626 | setTerminalOpen: (threadRef, open) => |
| 597 | updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), | 627 | updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), |
| 598 | setTerminalHeight: (threadId, height) => | 628 | setTerminalHeight: (threadRef, height) => |
| 599 | updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)), | 629 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), |
| 600 | splitTerminal: (threadId, terminalId) => | 630 | splitTerminal: (threadRef, terminalId) => |
| 601 | updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), | 631 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), |
| 602 | newTerminal: (threadId, terminalId) => | 632 | newTerminal: (threadRef, terminalId) => |
| 603 | updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), | 633 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), |
| 604 | ensureTerminal: (threadId, terminalId, options) => | 634 | ensureTerminal: (threadRef, terminalId, options) => |
| 605 | updateTerminal(threadId, (state) => { | 635 | updateTerminal(threadRef, (state) => { |
| 606 | let nextState = state; | 636 | let nextState = state; |
| 607 | if (!state.terminalIds.includes(terminalId)) { | 637 | if (!state.terminalIds.includes(terminalId)) { |
| 608 | nextState = newThreadTerminal(nextState, terminalId); | 638 | nextState = newThreadTerminal(nextState, terminalId); |
| ... | @@ -622,47 +652,49 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( | ... | @@ -622,47 +652,49 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 622 | } | 652 | } |
| 623 | return normalizeThreadTerminalState(nextState); | 653 | return normalizeThreadTerminalState(nextState); |
| 624 | }), | 654 | }), |
| 625 | setActiveTerminal: (threadId, terminalId) => | 655 | setActiveTerminal: (threadRef, terminalId) => |
| 626 | updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), | 656 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), |
| 627 | closeTerminal: (threadId, terminalId) => | 657 | closeTerminal: (threadRef, terminalId) => |
| 628 | updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), | 658 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), |
| 629 | setTerminalLaunchContext: (threadId, context) => | 659 | setTerminalLaunchContext: (threadRef, context) => |
| 630 | set((state) => ({ | 660 | set((state) => ({ |
| 631 | terminalLaunchContextByThreadId: { | 661 | terminalLaunchContextByThreadKey: { |
| 632 | ...state.terminalLaunchContextByThreadId, | 662 | ...state.terminalLaunchContextByThreadKey, |
| 633 | [threadId]: context, | 663 | [terminalThreadKey(threadRef)]: context, |
| 634 | }, | 664 | }, |
| 635 | })), | 665 | })), |
| 636 | clearTerminalLaunchContext: (threadId) => | 666 | clearTerminalLaunchContext: (threadRef) => |
| 637 | set((state) => { | 667 | set((state) => { |
| 638 | if (!state.terminalLaunchContextByThreadId[threadId]) { | 668 | const threadKey = terminalThreadKey(threadRef); |
| 669 | if (!state.terminalLaunchContextByThreadKey[threadKey]) { | ||
| 639 | return state; | 670 | return state; |
| 640 | } | 671 | } |
| 641 | const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId; | 672 | const { [threadKey]: _removed, ...rest } = state.terminalLaunchContextByThreadKey; |
| 642 | return { terminalLaunchContextByThreadId: rest }; | 673 | return { terminalLaunchContextByThreadKey: rest }; |
| 643 | }), | 674 | }), |
| 644 | setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => | 675 | setTerminalActivity: (threadRef, terminalId, hasRunningSubprocess) => |
| 645 | updateTerminal(threadId, (state) => | 676 | updateTerminal(threadRef, (state) => |
| 646 | setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), | 677 | setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), |
| 647 | ), | 678 | ), |
| 648 | recordTerminalEvent: (event) => | 679 | recordTerminalEvent: (threadRef, event) => |
| 649 | set((state) => | 680 | set((state) => |
| 650 | appendTerminalEventEntry( | 681 | appendTerminalEventEntry( |
| 651 | state.terminalEventEntriesByKey, | 682 | state.terminalEventEntriesByKey, |
| 652 | state.nextTerminalEventId, | 683 | state.nextTerminalEventId, |
| 684 | threadRef, | ||
| 653 | event, | 685 | event, |
| 654 | ), | 686 | ), |
| 655 | ), | 687 | ), |
| 656 | applyTerminalEvent: (event) => | 688 | applyTerminalEvent: (threadRef, event) => |
| 657 | set((state) => { | 689 | set((state) => { |
| 658 | const threadId = ThreadId.makeUnsafe(event.threadId); | 690 | const threadKey = terminalThreadKey(threadRef); |
| 659 | let nextTerminalStateByThreadId = state.terminalStateByThreadId; | 691 | let nextTerminalStateByThreadKey = state.terminalStateByThreadKey; |
| 660 | let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId; | 692 | let nextTerminalLaunchContextByThreadKey = state.terminalLaunchContextByThreadKey; |
| 661 | 693 | ||
| 662 | if (event.type === "started" || event.type === "restarted") { | 694 | if (event.type === "started" || event.type === "restarted") { |
| 663 | nextTerminalStateByThreadId = updateTerminalStateByThreadId( | 695 | nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( |
| 664 | nextTerminalStateByThreadId, | 696 | nextTerminalStateByThreadKey, |
| 665 | threadId, | 697 | threadRef, |
| 666 | (current) => { | 698 | (current) => { |
| 667 | let nextState = current; | 699 | let nextState = current; |
| 668 | if (!current.terminalIds.includes(event.terminalId)) { | 700 | if (!current.terminalIds.includes(event.terminalId)) { |
| ... | @@ -673,17 +705,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( | ... | @@ -673,17 +705,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 673 | return normalizeThreadTerminalState(nextState); | 705 | return normalizeThreadTerminalState(nextState); |
| 674 | }, | 706 | }, |
| 675 | ); | 707 | ); |
| 676 | nextTerminalLaunchContextByThreadId = { | 708 | nextTerminalLaunchContextByThreadKey = { |
| 677 | ...nextTerminalLaunchContextByThreadId, | 709 | ...nextTerminalLaunchContextByThreadKey, |
| 678 | [threadId]: launchContextFromStartEvent(event), | 710 | [threadKey]: launchContextFromStartEvent(event), |
| 679 | }; | 711 | }; |
| 680 | } | 712 | } |
| 681 | 713 | ||
| 682 | const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); | 714 | const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); |
| 683 | if (hasRunningSubprocess !== null) { | 715 | if (hasRunningSubprocess !== null) { |
| 684 | nextTerminalStateByThreadId = updateTerminalStateByThreadId( | 716 | nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( |
| 685 | nextTerminalStateByThreadId, | 717 | nextTerminalStateByThreadKey, |
| 686 | threadId, | 718 | threadRef, |
| 687 | (current) => | 719 | (current) => |
| 688 | setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), | 720 | setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), |
| 689 | ); | 721 | ); |
| ... | @@ -692,54 +724,59 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( | ... | @@ -692,54 +724,59 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 692 | const nextEventState = appendTerminalEventEntry( | 724 | const nextEventState = appendTerminalEventEntry( |
| 693 | state.terminalEventEntriesByKey, | 725 | state.terminalEventEntriesByKey, |
| 694 | state.nextTerminalEventId, | 726 | state.nextTerminalEventId, |
| 727 | threadRef, | ||
| 695 | event, | 728 | event, |
| 696 | ); | 729 | ); |
| 697 | 730 | ||
| 698 | return { | 731 | return { |
| 699 | terminalStateByThreadId: nextTerminalStateByThreadId, | 732 | terminalStateByThreadKey: nextTerminalStateByThreadKey, |
| 700 | terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId, | 733 | terminalLaunchContextByThreadKey: nextTerminalLaunchContextByThreadKey, |
| 701 | ...nextEventState, | 734 | ...nextEventState, |
| 702 | }; | 735 | }; |
| 703 | }), | 736 | }), |
| 704 | clearTerminalState: (threadId) => | 737 | clearTerminalState: (threadRef) => |
| 705 | set((state) => { | 738 | set((state) => { |
| 706 | const nextTerminalStateByThreadId = updateTerminalStateByThreadId( | 739 | const threadKey = terminalThreadKey(threadRef); |
| 707 | state.terminalStateByThreadId, | 740 | const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( |
| 708 | threadId, | 741 | state.terminalStateByThreadKey, |
| 742 | threadRef, | ||
| 709 | () => createDefaultThreadTerminalState(), | 743 | () => createDefaultThreadTerminalState(), |
| 710 | ); | 744 | ); |
| 711 | const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; | 745 | const hadLaunchContext = |
| 712 | const { [threadId]: _removed, ...remainingLaunchContexts } = | 746 | state.terminalLaunchContextByThreadKey[threadKey] !== undefined; |
| 713 | state.terminalLaunchContextByThreadId; | 747 | const { [threadKey]: _removed, ...remainingLaunchContexts } = |
| 748 | state.terminalLaunchContextByThreadKey; | ||
| 714 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; | 749 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; |
| 715 | let removedEventEntries = false; | 750 | let removedEventEntries = false; |
| 716 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { | 751 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { |
| 717 | if (key.startsWith(`${threadId}\u0000`)) { | 752 | if (key.startsWith(`${threadKey}\u0000`)) { |
| 718 | delete nextTerminalEventEntriesByKey[key]; | 753 | delete nextTerminalEventEntriesByKey[key]; |
| 719 | removedEventEntries = true; | 754 | removedEventEntries = true; |
| 720 | } | 755 | } |
| 721 | } | 756 | } |
| 722 | if ( | 757 | if ( |
| 723 | nextTerminalStateByThreadId === state.terminalStateByThreadId && | 758 | nextTerminalStateByThreadKey === state.terminalStateByThreadKey && |
| 724 | !hadLaunchContext && | 759 | !hadLaunchContext && |
| 725 | !removedEventEntries | 760 | !removedEventEntries |
| 726 | ) { | 761 | ) { |
| 727 | return state; | 762 | return state; |
| 728 | } | 763 | } |
| 729 | return { | 764 | return { |
| 730 | terminalStateByThreadId: nextTerminalStateByThreadId, | 765 | terminalStateByThreadKey: nextTerminalStateByThreadKey, |
| 731 | terminalLaunchContextByThreadId: remainingLaunchContexts, | 766 | terminalLaunchContextByThreadKey: remainingLaunchContexts, |
| 732 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, | 767 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, |
| 733 | }; | 768 | }; |
| 734 | }), | 769 | }), |
| 735 | removeTerminalState: (threadId) => | 770 | removeTerminalState: (threadRef) => |
| 736 | set((state) => { | 771 | set((state) => { |
| 737 | const hadTerminalState = state.terminalStateByThreadId[threadId] !== undefined; | 772 | const threadKey = terminalThreadKey(threadRef); |
| 738 | const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; | 773 | const hadTerminalState = state.terminalStateByThreadKey[threadKey] !== undefined; |
| 774 | const hadLaunchContext = | ||
| 775 | state.terminalLaunchContextByThreadKey[threadKey] !== undefined; | ||
| 739 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; | 776 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; |
| 740 | let removedEventEntries = false; | 777 | let removedEventEntries = false; |
| 741 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { | 778 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { |
| 742 | if (key.startsWith(`${threadId}\u0000`)) { | 779 | if (key.startsWith(`${threadKey}\u0000`)) { |
| 743 | delete nextTerminalEventEntriesByKey[key]; | 780 | delete nextTerminalEventEntriesByKey[key]; |
| 744 | removedEventEntries = true; | 781 | removedEventEntries = true; |
| 745 | } | 782 | } |
| ... | @@ -747,29 +784,29 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( | ... | @@ -747,29 +784,29 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 747 | if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) { | 784 | if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) { |
| 748 | return state; | 785 | return state; |
| 749 | } | 786 | } |
| 750 | const nextTerminalStateByThreadId = { ...state.terminalStateByThreadId }; | 787 | const nextTerminalStateByThreadKey = { ...state.terminalStateByThreadKey }; |
| 751 | delete nextTerminalStateByThreadId[threadId]; | 788 | delete nextTerminalStateByThreadKey[threadKey]; |
| 752 | const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; | 789 | const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; |
| 753 | delete nextLaunchContexts[threadId]; | 790 | delete nextLaunchContexts[threadKey]; |
| 754 | return { | 791 | return { |
| 755 | terminalStateByThreadId: nextTerminalStateByThreadId, | 792 | terminalStateByThreadKey: nextTerminalStateByThreadKey, |
| 756 | terminalLaunchContextByThreadId: nextLaunchContexts, | 793 | terminalLaunchContextByThreadKey: nextLaunchContexts, |
| 757 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, | 794 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, |
| 758 | }; | 795 | }; |
| 759 | }), | 796 | }), |
| 760 | removeOrphanedTerminalStates: (activeThreadIds) => | 797 | removeOrphanedTerminalStates: (activeThreadKeys) => |
| 761 | set((state) => { | 798 | set((state) => { |
| 762 | const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( | 799 | const orphanedIds = Object.keys(state.terminalStateByThreadKey).filter( |
| 763 | (id) => !activeThreadIds.has(id as ThreadId), | 800 | (key) => !activeThreadKeys.has(key), |
| 764 | ); | 801 | ); |
| 765 | const orphanedLaunchContextIds = Object.keys( | 802 | const orphanedLaunchContextIds = Object.keys( |
| 766 | state.terminalLaunchContextByThreadId, | 803 | state.terminalLaunchContextByThreadKey, |
| 767 | ).filter((id) => !activeThreadIds.has(id as ThreadId)); | 804 | ).filter((key) => !activeThreadKeys.has(key)); |
| 768 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; | 805 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; |
| 769 | let removedEventEntries = false; | 806 | let removedEventEntries = false; |
| 770 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { | 807 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { |
| 771 | const [threadId] = key.split("\u0000"); | 808 | const [threadKey] = key.split("\u0000"); |
| 772 | if (threadId && !activeThreadIds.has(threadId as ThreadId)) { | 809 | if (threadKey && !activeThreadKeys.has(threadKey)) { |
| 773 | delete nextTerminalEventEntriesByKey[key]; | 810 | delete nextTerminalEventEntriesByKey[key]; |
| 774 | removedEventEntries = true; | 811 | removedEventEntries = true; |
| 775 | } | 812 | } |
| ... | @@ -781,17 +818,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( | ... | @@ -781,17 +818,17 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 781 | ) { | 818 | ) { |
| 782 | return state; | 819 | return state; |
| 783 | } | 820 | } |
| 784 | const next = { ...state.terminalStateByThreadId }; | 821 | const next = { ...state.terminalStateByThreadKey }; |
| 785 | for (const id of orphanedIds) { | 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 | for (const id of orphanedLaunchContextIds) { | 826 | for (const id of orphanedLaunchContextIds) { |
| 790 | delete nextLaunchContexts[id as ThreadId]; | 827 | delete nextLaunchContexts[id]; |
| 791 | } | 828 | } |
| 792 | return { | 829 | return { |
| 793 | terminalStateByThreadId: next, | 830 | terminalStateByThreadKey: next, |
| 794 | terminalLaunchContextByThreadId: nextLaunchContexts, | 831 | terminalLaunchContextByThreadKey: nextLaunchContexts, |
| 795 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, | 832 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, |
| 796 | }; | 833 | }; |
| 797 | }), | 834 | }), |
| ... | @@ -799,10 +836,11 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( | ... | @@ -799,10 +836,11 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 799 | }, | 836 | }, |
| 800 | { | 837 | { |
| 801 | name: TERMINAL_STATE_STORAGE_KEY, | 838 | name: TERMINAL_STATE_STORAGE_KEY, |
| 802 | version: 1, | 839 | version: 2, |
| 803 | storage: createJSONStorage(createTerminalStateStorage), | 840 | storage: createJSONStorage(createTerminalStateStorage), |
| 841 | migrate: migratePersistedTerminalStateStoreState, | ||
| 804 | partialize: (state) => ({ | 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,9 +21,9 @@ describe("threadSelectionStore", () => { |
| 21 | useThreadSelectionStore.getState().toggleThread(THREAD_A); | 21 | useThreadSelectionStore.getState().toggleThread(THREAD_A); |
| 22 | 22 | ||
| 23 | const state = useThreadSelectionStore.getState(); | 23 | const state = useThreadSelectionStore.getState(); |
| 24 | expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); | 24 | expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); |
| 25 | expect(state.selectedThreadIds.size).toBe(1); | 25 | expect(state.selectedThreadKeys.size).toBe(1); |
| 26 | expect(state.anchorThreadId).toBe(THREAD_A); | 26 | expect(state.anchorThreadKey).toBe(THREAD_A); |
| 27 | }); | 27 | }); |
| 28 | 28 | ||
| 29 | it("removes a thread that is already selected", () => { | 29 | it("removes a thread that is already selected", () => { |
| ... | @@ -32,8 +32,8 @@ describe("threadSelectionStore", () => { | ... | @@ -32,8 +32,8 @@ describe("threadSelectionStore", () => { |
| 32 | store.toggleThread(THREAD_A); | 32 | store.toggleThread(THREAD_A); |
| 33 | 33 | ||
| 34 | const state = useThreadSelectionStore.getState(); | 34 | const state = useThreadSelectionStore.getState(); |
| 35 | expect(state.selectedThreadIds.has(THREAD_A)).toBe(false); | 35 | expect(state.selectedThreadKeys.has(THREAD_A)).toBe(false); |
| 36 | expect(state.selectedThreadIds.size).toBe(0); | 36 | expect(state.selectedThreadKeys.size).toBe(0); |
| 37 | }); | 37 | }); |
| 38 | 38 | ||
| 39 | it("preserves existing selections when toggling a new thread", () => { | 39 | it("preserves existing selections when toggling a new thread", () => { |
| ... | @@ -42,9 +42,9 @@ describe("threadSelectionStore", () => { | ... | @@ -42,9 +42,9 @@ describe("threadSelectionStore", () => { |
| 42 | store.toggleThread(THREAD_B); | 42 | store.toggleThread(THREAD_B); |
| 43 | 43 | ||
| 44 | const state = useThreadSelectionStore.getState(); | 44 | const state = useThreadSelectionStore.getState(); |
| 45 | expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); | 45 | expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); |
| 46 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | 46 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); |
| 47 | expect(state.selectedThreadIds.size).toBe(2); | 47 | expect(state.selectedThreadKeys.size).toBe(2); |
| 48 | }); | 48 | }); |
| 49 | 49 | ||
| 50 | it("sets anchor to the newly added thread", () => { | 50 | it("sets anchor to the newly added thread", () => { |
| ... | @@ -52,7 +52,7 @@ describe("threadSelectionStore", () => { | ... | @@ -52,7 +52,7 @@ describe("threadSelectionStore", () => { |
| 52 | store.toggleThread(THREAD_A); | 52 | store.toggleThread(THREAD_A); |
| 53 | store.toggleThread(THREAD_B); | 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 | it("preserves anchor when deselecting a non-anchor thread", () => { | 58 | it("preserves anchor when deselecting a non-anchor thread", () => { |
| ... | @@ -61,7 +61,7 @@ describe("threadSelectionStore", () => { | ... | @@ -61,7 +61,7 @@ describe("threadSelectionStore", () => { |
| 61 | store.toggleThread(THREAD_B); | 61 | store.toggleThread(THREAD_B); |
| 62 | store.toggleThread(THREAD_A); // deselect A, anchor should stay B | 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,8 +70,8 @@ describe("threadSelectionStore", () => { |
| 70 | useThreadSelectionStore.getState().setAnchor(THREAD_B); | 70 | useThreadSelectionStore.getState().setAnchor(THREAD_B); |
| 71 | 71 | ||
| 72 | const state = useThreadSelectionStore.getState(); | 72 | const state = useThreadSelectionStore.getState(); |
| 73 | expect(state.anchorThreadId).toBe(THREAD_B); | 73 | expect(state.anchorThreadKey).toBe(THREAD_B); |
| 74 | expect(state.selectedThreadIds.size).toBe(0); | 74 | expect(state.selectedThreadKeys.size).toBe(0); |
| 75 | }); | 75 | }); |
| 76 | 76 | ||
| 77 | it("enables range select from a plain-click anchor", () => { | 77 | it("enables range select from a plain-click anchor", () => { |
| ... | @@ -80,10 +80,10 @@ describe("threadSelectionStore", () => { | ... | @@ -80,10 +80,10 @@ describe("threadSelectionStore", () => { |
| 80 | store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D | 80 | store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D |
| 81 | 81 | ||
| 82 | const state = useThreadSelectionStore.getState(); | 82 | const state = useThreadSelectionStore.getState(); |
| 83 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | 83 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); |
| 84 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | 84 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); |
| 85 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | 85 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); |
| 86 | expect(state.selectedThreadIds.size).toBe(3); | 86 | expect(state.selectedThreadKeys.size).toBe(3); |
| 87 | }); | 87 | }); |
| 88 | 88 | ||
| 89 | it("is a no-op when anchor is already set to the same thread", () => { | 89 | it("is a no-op when anchor is already set to the same thread", () => { |
| ... | @@ -105,8 +105,8 @@ describe("threadSelectionStore", () => { | ... | @@ -105,8 +105,8 @@ describe("threadSelectionStore", () => { |
| 105 | store.setAnchor(THREAD_C); | 105 | store.setAnchor(THREAD_C); |
| 106 | 106 | ||
| 107 | const state = useThreadSelectionStore.getState(); | 107 | const state = useThreadSelectionStore.getState(); |
| 108 | expect(state.anchorThreadId).toBe(THREAD_C); | 108 | expect(state.anchorThreadKey).toBe(THREAD_C); |
| 109 | expect(state.selectedThreadIds.size).toBe(0); | 109 | expect(state.selectedThreadKeys.size).toBe(0); |
| 110 | }); | 110 | }); |
| 111 | }); | 111 | }); |
| 112 | 112 | ||
| ... | @@ -115,9 +115,9 @@ describe("threadSelectionStore", () => { | ... | @@ -115,9 +115,9 @@ describe("threadSelectionStore", () => { |
| 115 | useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED); | 115 | useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED); |
| 116 | 116 | ||
| 117 | const state = useThreadSelectionStore.getState(); | 117 | const state = useThreadSelectionStore.getState(); |
| 118 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | 118 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); |
| 119 | expect(state.selectedThreadIds.size).toBe(1); | 119 | expect(state.selectedThreadKeys.size).toBe(1); |
| 120 | expect(state.anchorThreadId).toBe(THREAD_C); | 120 | expect(state.anchorThreadKey).toBe(THREAD_C); |
| 121 | }); | 121 | }); |
| 122 | 122 | ||
| 123 | it("selects range from anchor to target (forward)", () => { | 123 | it("selects range from anchor to target (forward)", () => { |
| ... | @@ -126,10 +126,10 @@ describe("threadSelectionStore", () => { | ... | @@ -126,10 +126,10 @@ describe("threadSelectionStore", () => { |
| 126 | store.rangeSelectTo(THREAD_D, ORDERED); | 126 | store.rangeSelectTo(THREAD_D, ORDERED); |
| 127 | 127 | ||
| 128 | const state = useThreadSelectionStore.getState(); | 128 | const state = useThreadSelectionStore.getState(); |
| 129 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | 129 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); |
| 130 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | 130 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); |
| 131 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | 131 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); |
| 132 | expect(state.selectedThreadIds.size).toBe(3); | 132 | expect(state.selectedThreadKeys.size).toBe(3); |
| 133 | }); | 133 | }); |
| 134 | 134 | ||
| 135 | it("selects range from anchor to target (backward)", () => { | 135 | it("selects range from anchor to target (backward)", () => { |
| ... | @@ -138,10 +138,10 @@ describe("threadSelectionStore", () => { | ... | @@ -138,10 +138,10 @@ describe("threadSelectionStore", () => { |
| 138 | store.rangeSelectTo(THREAD_B, ORDERED); | 138 | store.rangeSelectTo(THREAD_B, ORDERED); |
| 139 | 139 | ||
| 140 | const state = useThreadSelectionStore.getState(); | 140 | const state = useThreadSelectionStore.getState(); |
| 141 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | 141 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); |
| 142 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | 142 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); |
| 143 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | 143 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); |
| 144 | expect(state.selectedThreadIds.size).toBe(3); | 144 | expect(state.selectedThreadKeys.size).toBe(3); |
| 145 | }); | 145 | }); |
| 146 | 146 | ||
| 147 | it("keeps anchor stable across multiple range selects", () => { | 147 | it("keeps anchor stable across multiple range selects", () => { |
| ... | @@ -151,11 +151,11 @@ describe("threadSelectionStore", () => { | ... | @@ -151,11 +151,11 @@ describe("threadSelectionStore", () => { |
| 151 | store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B) | 151 | store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B) |
| 152 | 152 | ||
| 153 | const state = useThreadSelectionStore.getState(); | 153 | const state = useThreadSelectionStore.getState(); |
| 154 | expect(state.anchorThreadId).toBe(THREAD_B); | 154 | expect(state.anchorThreadKey).toBe(THREAD_B); |
| 155 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | 155 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); |
| 156 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | 156 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); |
| 157 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | 157 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); |
| 158 | expect(state.selectedThreadIds.has(THREAD_E)).toBe(true); | 158 | expect(state.selectedThreadKeys.has(THREAD_E)).toBe(true); |
| 159 | }); | 159 | }); |
| 160 | 160 | ||
| 161 | it("falls back to toggle when anchor is not in the ordered list", () => { | 161 | it("falls back to toggle when anchor is not in the ordered list", () => { |
| ... | @@ -166,8 +166,8 @@ describe("threadSelectionStore", () => { | ... | @@ -166,8 +166,8 @@ describe("threadSelectionStore", () => { |
| 166 | 166 | ||
| 167 | const state = useThreadSelectionStore.getState(); | 167 | const state = useThreadSelectionStore.getState(); |
| 168 | // Should have added C and reset anchor to C | 168 | // Should have added C and reset anchor to C |
| 169 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | 169 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); |
| 170 | expect(state.anchorThreadId).toBe(THREAD_C); | 170 | expect(state.anchorThreadKey).toBe(THREAD_C); |
| 171 | }); | 171 | }); |
| 172 | 172 | ||
| 173 | it("falls back to toggle when target is not in the ordered list", () => { | 173 | it("falls back to toggle when target is not in the ordered list", () => { |
| ... | @@ -177,8 +177,8 @@ describe("threadSelectionStore", () => { | ... | @@ -177,8 +177,8 @@ describe("threadSelectionStore", () => { |
| 177 | store.rangeSelectTo(unknownThread, ORDERED); | 177 | store.rangeSelectTo(unknownThread, ORDERED); |
| 178 | 178 | ||
| 179 | const state = useThreadSelectionStore.getState(); | 179 | const state = useThreadSelectionStore.getState(); |
| 180 | expect(state.selectedThreadIds.has(unknownThread)).toBe(true); | 180 | expect(state.selectedThreadKeys.has(unknownThread)).toBe(true); |
| 181 | expect(state.anchorThreadId).toBe(unknownThread); | 181 | expect(state.anchorThreadKey).toBe(unknownThread); |
| 182 | }); | 182 | }); |
| 183 | 183 | ||
| 184 | it("selects the single thread when anchor equals target", () => { | 184 | it("selects the single thread when anchor equals target", () => { |
| ... | @@ -187,8 +187,8 @@ describe("threadSelectionStore", () => { | ... | @@ -187,8 +187,8 @@ describe("threadSelectionStore", () => { |
| 187 | store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C | 187 | store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C |
| 188 | 188 | ||
| 189 | const state = useThreadSelectionStore.getState(); | 189 | const state = useThreadSelectionStore.getState(); |
| 190 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | 190 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); |
| 191 | expect(state.selectedThreadIds.size).toBe(1); | 191 | expect(state.selectedThreadKeys.size).toBe(1); |
| 192 | }); | 192 | }); |
| 193 | 193 | ||
| 194 | it("preserves previously selected threads outside the range", () => { | 194 | it("preserves previously selected threads outside the range", () => { |
| ... | @@ -200,11 +200,11 @@ describe("threadSelectionStore", () => { | ... | @@ -200,11 +200,11 @@ describe("threadSelectionStore", () => { |
| 200 | store.rangeSelectTo(THREAD_D, ORDERED); | 200 | store.rangeSelectTo(THREAD_D, ORDERED); |
| 201 | 201 | ||
| 202 | const state = useThreadSelectionStore.getState(); | 202 | const state = useThreadSelectionStore.getState(); |
| 203 | expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); | 203 | expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); |
| 204 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | 204 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); |
| 205 | expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); | 205 | expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); |
| 206 | expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); | 206 | expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); |
| 207 | expect(state.selectedThreadIds.size).toBe(4); | 207 | expect(state.selectedThreadKeys.size).toBe(4); |
| 208 | }); | 208 | }); |
| 209 | }); | 209 | }); |
| 210 | 210 | ||
| ... | @@ -216,8 +216,8 @@ describe("threadSelectionStore", () => { | ... | @@ -216,8 +216,8 @@ describe("threadSelectionStore", () => { |
| 216 | store.clearSelection(); | 216 | store.clearSelection(); |
| 217 | 217 | ||
| 218 | const state = useThreadSelectionStore.getState(); | 218 | const state = useThreadSelectionStore.getState(); |
| 219 | expect(state.selectedThreadIds.size).toBe(0); | 219 | expect(state.selectedThreadKeys.size).toBe(0); |
| 220 | expect(state.anchorThreadId).toBeNull(); | 220 | expect(state.anchorThreadKey).toBeNull(); |
| 221 | }); | 221 | }); |
| 222 | 222 | ||
| 223 | it("is a no-op when already empty", () => { | 223 | it("is a no-op when already empty", () => { |
| ... | @@ -226,7 +226,7 @@ describe("threadSelectionStore", () => { | ... | @@ -226,7 +226,7 @@ describe("threadSelectionStore", () => { |
| 226 | const stateAfter = useThreadSelectionStore.getState(); | 226 | const stateAfter = useThreadSelectionStore.getState(); |
| 227 | 227 | ||
| 228 | // Should be referentially the same (no unnecessary re-render) | 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,8 +239,8 @@ describe("threadSelectionStore", () => { |
| 239 | store.removeFromSelection([THREAD_A, THREAD_C]); | 239 | store.removeFromSelection([THREAD_A, THREAD_C]); |
| 240 | 240 | ||
| 241 | const state = useThreadSelectionStore.getState(); | 241 | const state = useThreadSelectionStore.getState(); |
| 242 | expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); | 242 | expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); |
| 243 | expect(state.selectedThreadIds.size).toBe(1); | 243 | expect(state.selectedThreadKeys.size).toBe(1); |
| 244 | }); | 244 | }); |
| 245 | 245 | ||
| 246 | it("clears anchor when the anchor thread is removed", () => { | 246 | it("clears anchor when the anchor thread is removed", () => { |
| ... | @@ -249,7 +249,7 @@ describe("threadSelectionStore", () => { | ... | @@ -249,7 +249,7 @@ describe("threadSelectionStore", () => { |
| 249 | store.toggleThread(THREAD_B); // anchor = B | 249 | store.toggleThread(THREAD_B); // anchor = B |
| 250 | store.removeFromSelection([THREAD_B]); | 250 | store.removeFromSelection([THREAD_B]); |
| 251 | 251 | ||
| 252 | expect(useThreadSelectionStore.getState().anchorThreadId).toBeNull(); | 252 | expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); |
| 253 | }); | 253 | }); |
| 254 | 254 | ||
| 255 | it("preserves anchor when the anchor thread is not removed", () => { | 255 | it("preserves anchor when the anchor thread is not removed", () => { |
| ... | @@ -258,7 +258,7 @@ describe("threadSelectionStore", () => { | ... | @@ -258,7 +258,7 @@ describe("threadSelectionStore", () => { |
| 258 | store.toggleThread(THREAD_B); // anchor = B | 258 | store.toggleThread(THREAD_B); // anchor = B |
| 259 | store.removeFromSelection([THREAD_A]); | 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 | it("is a no-op when none of the specified threads are selected", () => { | 264 | it("is a no-op when none of the specified threads are selected", () => { |
| ... | @@ -268,7 +268,7 @@ describe("threadSelectionStore", () => { | ... | @@ -268,7 +268,7 @@ describe("threadSelectionStore", () => { |
| 268 | store.removeFromSelection([THREAD_B, THREAD_C]); | 268 | store.removeFromSelection([THREAD_B, THREAD_C]); |
| 269 | const stateAfter = useThreadSelectionStore.getState(); | 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,121 +4,119 @@ |
| 4 | * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select), | 4 | * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select), |
| 5 | * and bulk actions on the selected set. | 5 | * and bulk actions on the selected set. |
| 6 | */ | 6 | */ |
| 7 | |||
| 8 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 9 | import { create } from "zustand"; | 7 | import { create } from "zustand"; |
| 10 | 8 | ||
| 11 | export interface ThreadSelectionState { | 9 | export interface ThreadSelectionState { |
| 12 | /** Currently selected thread IDs. */ | 10 | /** Currently selected scoped thread keys. */ |
| 13 | selectedThreadIds: ReadonlySet<ThreadId>; | 11 | selectedThreadKeys: ReadonlySet<string>; |
| 14 | /** The thread ID that anchors shift-click range selection. */ | 12 | /** The scoped thread key that anchors shift-click range selection. */ |
| 15 | anchorThreadId: ThreadId | null; | 13 | anchorThreadKey: string | null; |
| 16 | } | 14 | } |
| 17 | 15 | ||
| 18 | interface ThreadSelectionStore extends ThreadSelectionState { | 16 | interface ThreadSelectionStore extends ThreadSelectionState { |
| 19 | /** Toggle a single thread in the selection (Cmd/Ctrl+Click). */ | 17 | /** Toggle a single scoped thread key in the selection (Cmd/Ctrl+Click). */ |
| 20 | toggleThread: (threadId: ThreadId) => void; | 18 | toggleThread: (threadKey: string) => void; |
| 21 | /** | 19 | /** |
| 22 | * Select a range of threads (Shift+Click). | 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 | * so the store can compute which threads fall between anchor and target. | 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 | /** Clear all selection state. */ | 25 | /** Clear all selection state. */ |
| 28 | clearSelection: () => void; | 26 | clearSelection: () => void; |
| 29 | /** Remove specific thread IDs from the selection (e.g. after deletion). */ | 27 | /** Remove specific scoped thread keys from the selection (e.g. after deletion). */ |
| 30 | removeFromSelection: (threadIds: readonly ThreadId[]) => void; | 28 | removeFromSelection: (threadKeys: readonly string[]) => void; |
| 31 | /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */ | 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 | /** Check if any threads are selected. */ | 31 | /** Check if any threads are selected. */ |
| 34 | hasSelection: () => boolean; | 32 | hasSelection: () => boolean; |
| 35 | } | 33 | } |
| 36 | 34 | ||
| 37 | const EMPTY_SET = new Set<ThreadId>(); | 35 | const EMPTY_SET = new Set<string>(); |
| 38 | 36 | ||
| 39 | export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({ | 37 | export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({ |
| 40 | selectedThreadIds: EMPTY_SET, | 38 | selectedThreadKeys: EMPTY_SET, |
| 41 | anchorThreadId: null, | 39 | anchorThreadKey: null, |
| 42 | 40 | ||
| 43 | toggleThread: (threadId) => { | 41 | toggleThread: (threadKey) => { |
| 44 | set((state) => { | 42 | set((state) => { |
| 45 | const next = new Set(state.selectedThreadIds); | 43 | const next = new Set(state.selectedThreadKeys); |
| 46 | if (next.has(threadId)) { | 44 | if (next.has(threadKey)) { |
| 47 | next.delete(threadId); | 45 | next.delete(threadKey); |
| 48 | } else { | 46 | } else { |
| 49 | next.add(threadId); | 47 | next.add(threadKey); |
| 50 | } | 48 | } |
| 51 | return { | 49 | return { |
| 52 | selectedThreadIds: next, | 50 | selectedThreadKeys: next, |
| 53 | anchorThreadId: next.has(threadId) ? threadId : state.anchorThreadId, | 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 | set((state) => { | 57 | set((state) => { |
| 60 | const anchor = state.anchorThreadId; | 58 | const anchor = state.anchorThreadKey; |
| 61 | if (anchor === null) { | 59 | if (anchor === null) { |
| 62 | // No anchor yet — treat as a single toggle | 60 | // No anchor yet — treat as a single toggle |
| 63 | const next = new Set(state.selectedThreadIds); | 61 | const next = new Set(state.selectedThreadKeys); |
| 64 | next.add(threadId); | 62 | next.add(threadKey); |
| 65 | return { selectedThreadIds: next, anchorThreadId: threadId }; | 63 | return { selectedThreadKeys: next, anchorThreadKey: threadKey }; |
| 66 | } | 64 | } |
| 67 | 65 | ||
| 68 | const anchorIndex = orderedThreadIds.indexOf(anchor); | 66 | const anchorIndex = orderedThreadKeys.indexOf(anchor); |
| 69 | const targetIndex = orderedThreadIds.indexOf(threadId); | 67 | const targetIndex = orderedThreadKeys.indexOf(threadKey); |
| 70 | if (anchorIndex === -1 || targetIndex === -1) { | 68 | if (anchorIndex === -1 || targetIndex === -1) { |
| 71 | // Anchor or target not in this list (different project?) — fallback to toggle | 69 | // Anchor or target not in this list (different project?) — fallback to toggle |
| 72 | const next = new Set(state.selectedThreadIds); | 70 | const next = new Set(state.selectedThreadKeys); |
| 73 | next.add(threadId); | 71 | next.add(threadKey); |
| 74 | return { selectedThreadIds: next, anchorThreadId: threadId }; | 72 | return { selectedThreadKeys: next, anchorThreadKey: threadKey }; |
| 75 | } | 73 | } |
| 76 | 74 | ||
| 77 | const start = Math.min(anchorIndex, targetIndex); | 75 | const start = Math.min(anchorIndex, targetIndex); |
| 78 | const end = Math.max(anchorIndex, targetIndex); | 76 | const end = Math.max(anchorIndex, targetIndex); |
| 79 | const next = new Set(state.selectedThreadIds); | 77 | const next = new Set(state.selectedThreadKeys); |
| 80 | for (let i = start; i <= end; i++) { | 78 | for (let i = start; i <= end; i++) { |
| 81 | const id = orderedThreadIds[i]; | 79 | const key = orderedThreadKeys[i]; |
| 82 | if (id !== undefined) { | 80 | if (key !== undefined) { |
| 83 | next.add(id); | 81 | next.add(key); |
| 84 | } | 82 | } |
| 85 | } | 83 | } |
| 86 | // Keep anchor stable so subsequent shift-clicks extend from the same point | 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 | clearSelection: () => { | 89 | clearSelection: () => { |
| 92 | const state = get(); | 90 | const state = get(); |
| 93 | if (state.selectedThreadIds.size === 0 && state.anchorThreadId === null) return; | 91 | if (state.selectedThreadKeys.size === 0 && state.anchorThreadKey === null) return; |
| 94 | set({ selectedThreadIds: EMPTY_SET, anchorThreadId: null }); | 92 | set({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null }); |
| 95 | }, | 93 | }, |
| 96 | 94 | ||
| 97 | setAnchor: (threadId) => { | 95 | setAnchor: (threadKey) => { |
| 98 | if (get().anchorThreadId === threadId) return; | 96 | if (get().anchorThreadKey === threadKey) return; |
| 99 | set({ anchorThreadId: threadId }); | 97 | set({ anchorThreadKey: threadKey }); |
| 100 | }, | 98 | }, |
| 101 | 99 | ||
| 102 | removeFromSelection: (threadIds) => { | 100 | removeFromSelection: (threadKeys) => { |
| 103 | set((state) => { | 101 | set((state) => { |
| 104 | const toRemove = new Set(threadIds); | 102 | const toRemove = new Set(threadKeys); |
| 105 | let changed = false; | 103 | let changed = false; |
| 106 | const next = new Set<ThreadId>(); | 104 | const next = new Set<string>(); |
| 107 | for (const id of state.selectedThreadIds) { | 105 | for (const key of state.selectedThreadKeys) { |
| 108 | if (toRemove.has(id)) { | 106 | if (toRemove.has(key)) { |
| 109 | changed = true; | 107 | changed = true; |
| 110 | } else { | 108 | } else { |
| 111 | next.add(id); | 109 | next.add(key); |
| 112 | } | 110 | } |
| 113 | } | 111 | } |
| 114 | if (!changed) return state; | 112 | if (!changed) return state; |
| 115 | const newAnchor = | 113 | const newAnchor = |
| 116 | state.anchorThreadId !== null && toRemove.has(state.anchorThreadId) | 114 | state.anchorThreadKey !== null && toRemove.has(state.anchorThreadKey) |
| 117 | ? null | 115 | ? null |
| 118 | : state.anchorThreadId; | 116 | : state.anchorThreadKey; |
| 119 | return { selectedThreadIds: next, anchorThreadId: newAnchor }; | 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,7 +1,9 @@ |
| 1 | import type { | 1 | import type { |
| 2 | EnvironmentId, | ||
| 2 | ModelSelection, | 3 | ModelSelection, |
| 3 | OrchestrationLatestTurn, | 4 | OrchestrationLatestTurn, |
| 4 | OrchestrationProposedPlanId, | 5 | OrchestrationProposedPlanId, |
| 6 | RepositoryIdentity, | ||
| 5 | OrchestrationSessionStatus, | 7 | OrchestrationSessionStatus, |
| 6 | OrchestrationThreadActivity, | 8 | OrchestrationThreadActivity, |
| 7 | ProjectScript as ContractProjectScript, | 9 | ProjectScript as ContractProjectScript, |
| ... | @@ -80,8 +82,10 @@ export interface TurnDiffSummary { | ... | @@ -80,8 +82,10 @@ export interface TurnDiffSummary { |
| 80 | 82 | ||
| 81 | export interface Project { | 83 | export interface Project { |
| 82 | id: ProjectId; | 84 | id: ProjectId; |
| 85 | environmentId: EnvironmentId; | ||
| 83 | name: string; | 86 | name: string; |
| 84 | cwd: string; | 87 | cwd: string; |
| 88 | repositoryIdentity?: RepositoryIdentity | null; | ||
| 85 | defaultModelSelection: ModelSelection | null; | 89 | defaultModelSelection: ModelSelection | null; |
| 86 | createdAt?: string | undefined; | 90 | createdAt?: string | undefined; |
| 87 | updatedAt?: string | undefined; | 91 | updatedAt?: string | undefined; |
| ... | @@ -90,6 +94,7 @@ export interface Project { | ... | @@ -90,6 +94,7 @@ export interface Project { |
| 90 | 94 | ||
| 91 | export interface Thread { | 95 | export interface Thread { |
| 92 | id: ThreadId; | 96 | id: ThreadId; |
| 97 | environmentId: EnvironmentId; | ||
| 93 | codexThreadId: string | null; | 98 | codexThreadId: string | null; |
| 94 | projectId: ProjectId; | 99 | projectId: ProjectId; |
| 95 | title: string; | 100 | title: string; |
| ... | @@ -113,6 +118,7 @@ export interface Thread { | ... | @@ -113,6 +118,7 @@ export interface Thread { |
| 113 | 118 | ||
| 114 | export interface ThreadShell { | 119 | export interface ThreadShell { |
| 115 | id: ThreadId; | 120 | id: ThreadId; |
| 121 | environmentId: EnvironmentId; | ||
| 116 | codexThreadId: string | null; | 122 | codexThreadId: string | null; |
| 117 | projectId: ProjectId; | 123 | projectId: ProjectId; |
| 118 | title: string; | 124 | title: string; |
| ... | @@ -134,6 +140,7 @@ export interface ThreadTurnState { | ... | @@ -134,6 +140,7 @@ export interface ThreadTurnState { |
| 134 | 140 | ||
| 135 | export interface SidebarThreadSummary { | 141 | export interface SidebarThreadSummary { |
| 136 | id: ThreadId; | 142 | id: ThreadId; |
| 143 | environmentId: EnvironmentId; | ||
| 137 | projectId: ProjectId; | 144 | projectId: ProjectId; |
| 138 | title: string; | 145 | title: string; |
| 139 | interactionMode: ProviderInteractionMode; | 146 | interactionMode: ProviderInteractionMode; |
apps/web/src/uiStateStore.test.ts+11-11| ... | @@ -74,9 +74,9 @@ describe("uiStateStore pure functions", () => { | ... | @@ -74,9 +74,9 @@ describe("uiStateStore pure functions", () => { |
| 74 | }); | 74 | }); |
| 75 | 75 | ||
| 76 | const next = syncProjects(initialState, [ | 76 | const next = syncProjects(initialState, [ |
| 77 | { id: project1, cwd: "/tmp/project-1" }, | 77 | { key: project1, cwd: "/tmp/project-1" }, |
| 78 | { id: project2, cwd: "/tmp/project-2" }, | 78 | { key: project2, cwd: "/tmp/project-2" }, |
| 79 | { id: project3, cwd: "/tmp/project-3" }, | 79 | { key: project3, cwd: "/tmp/project-3" }, |
| 80 | ]); | 80 | ]); |
| 81 | 81 | ||
| 82 | expect(next.projectOrder).toEqual([project2, project1, project3]); | 82 | expect(next.projectOrder).toEqual([project2, project1, project3]); |
| ... | @@ -96,14 +96,14 @@ describe("uiStateStore pure functions", () => { | ... | @@ -96,14 +96,14 @@ describe("uiStateStore pure functions", () => { |
| 96 | projectOrder: [oldProject2, oldProject1], | 96 | projectOrder: [oldProject2, oldProject1], |
| 97 | }), | 97 | }), |
| 98 | [ | 98 | [ |
| 99 | { id: oldProject1, cwd: "/tmp/project-1" }, | 99 | { key: oldProject1, cwd: "/tmp/project-1" }, |
| 100 | { id: oldProject2, cwd: "/tmp/project-2" }, | 100 | { key: oldProject2, cwd: "/tmp/project-2" }, |
| 101 | ], | 101 | ], |
| 102 | ); | 102 | ); |
| 103 | 103 | ||
| 104 | const next = syncProjects(initialState, [ | 104 | const next = syncProjects(initialState, [ |
| 105 | { id: oldProject1, cwd: "/tmp/project-1" }, | 105 | { key: oldProject1, cwd: "/tmp/project-1" }, |
| 106 | { id: recreatedProject2, cwd: "/tmp/project-2" }, | 106 | { key: recreatedProject2, cwd: "/tmp/project-2" }, |
| 107 | ]); | 107 | ]); |
| 108 | 108 | ||
| 109 | expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); | 109 | expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); |
| ... | @@ -119,10 +119,10 @@ describe("uiStateStore pure functions", () => { | ... | @@ -119,10 +119,10 @@ describe("uiStateStore pure functions", () => { |
| 119 | }, | 119 | }, |
| 120 | projectOrder: [project1], | 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 | expect(next).not.toBe(initialState); | 127 | expect(next).not.toBe(initialState); |
| 128 | expect(next.projectOrder).toEqual([project1]); | 128 | expect(next.projectOrder).toEqual([project1]); |
| ... | @@ -139,7 +139,7 @@ describe("uiStateStore pure functions", () => { | ... | @@ -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 | expect(next.threadLastVisitedAtById).toEqual({ | 144 | expect(next.threadLastVisitedAtById).toEqual({ |
| 145 | [thread1]: "2026-02-25T12:35:00.000Z", | 145 | [thread1]: "2026-02-25T12:35:00.000Z", |
| ... | @@ -152,7 +152,7 @@ describe("uiStateStore pure functions", () => { | ... | @@ -152,7 +152,7 @@ describe("uiStateStore pure functions", () => { |
| 152 | 152 | ||
| 153 | const next = syncThreads(initialState, [ | 153 | const next = syncThreads(initialState, [ |
| 154 | { | 154 | { |
| 155 | id: thread1, | 155 | key: thread1, |
| 156 | seedVisitedAt: "2026-02-25T12:35:00.000Z", | 156 | seedVisitedAt: "2026-02-25T12:35:00.000Z", |
| 157 | }, | 157 | }, |
| 158 | ]); | 158 | ]); |
apps/web/src/uiStateStore.ts+30-35| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | import { Debouncer } from "@tanstack/react-pacer"; | 1 | import { Debouncer } from "@tanstack/react-pacer"; |
| 2 | import { type ProjectId, type ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { create } from "zustand"; | 2 | import { create } from "zustand"; |
| 4 | 3 | ||
| 5 | const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; | 4 | const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; |
| ... | @@ -23,7 +22,7 @@ interface PersistedUiState { | ... | @@ -23,7 +22,7 @@ interface PersistedUiState { |
| 23 | 22 | ||
| 24 | export interface UiProjectState { | 23 | export interface UiProjectState { |
| 25 | projectExpandedById: Record<string, boolean>; | 24 | projectExpandedById: Record<string, boolean>; |
| 26 | projectOrder: ProjectId[]; | 25 | projectOrder: string[]; |
| 27 | } | 26 | } |
| 28 | 27 | ||
| 29 | export interface UiThreadState { | 28 | export interface UiThreadState { |
| ... | @@ -33,12 +32,12 @@ export interface UiThreadState { | ... | @@ -33,12 +32,12 @@ export interface UiThreadState { |
| 33 | export interface UiState extends UiProjectState, UiThreadState {} | 32 | export interface UiState extends UiProjectState, UiThreadState {} |
| 34 | 33 | ||
| 35 | export interface SyncProjectInput { | 34 | export interface SyncProjectInput { |
| 36 | id: ProjectId; | 35 | key: string; |
| 37 | cwd: string; | 36 | cwd: string; |
| 38 | } | 37 | } |
| 39 | 38 | ||
| 40 | export interface SyncThreadInput { | 39 | export interface SyncThreadInput { |
| 41 | id: ThreadId; | 40 | key: string; |
| 42 | seedVisitedAt?: string | undefined; | 41 | seedVisitedAt?: string | undefined; |
| 43 | } | 42 | } |
| 44 | 43 | ||
| ... | @@ -50,7 +49,7 @@ const initialState: UiState = { | ... | @@ -50,7 +49,7 @@ const initialState: UiState = { |
| 50 | 49 | ||
| 51 | const persistedExpandedProjectCwds = new Set<string>(); | 50 | const persistedExpandedProjectCwds = new Set<string>(); |
| 52 | const persistedProjectOrderCwds: string[] = []; | 51 | const persistedProjectOrderCwds: string[] = []; |
| 53 | const currentProjectCwdById = new Map<ProjectId, string>(); | 52 | const currentProjectCwdById = new Map<string, string>(); |
| 54 | let legacyKeysCleanedUp = false; | 53 | let legacyKeysCleanedUp = false; |
| 55 | 54 | ||
| 56 | function readPersistedState(): UiState { | 55 | function readPersistedState(): UiState { |
| ... | @@ -100,7 +99,7 @@ function persistState(state: UiState): void { | ... | @@ -100,7 +99,7 @@ function persistState(state: UiState): void { |
| 100 | const expandedProjectCwds = Object.entries(state.projectExpandedById) | 99 | const expandedProjectCwds = Object.entries(state.projectExpandedById) |
| 101 | .filter(([, expanded]) => expanded) | 100 | .filter(([, expanded]) => expanded) |
| 102 | .flatMap(([projectId]) => { | 101 | .flatMap(([projectId]) => { |
| 103 | const cwd = currentProjectCwdById.get(projectId as ProjectId); | 102 | const cwd = currentProjectCwdById.get(projectId); |
| 104 | return cwd ? [cwd] : []; | 103 | return cwd ? [cwd] : []; |
| 105 | }); | 104 | }); |
| 106 | const projectOrderCwds = state.projectOrder.flatMap((projectId) => { | 105 | const projectOrderCwds = state.projectOrder.flatMap((projectId) => { |
| ... | @@ -141,7 +140,7 @@ function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boo | ... | @@ -141,7 +140,7 @@ function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boo |
| 141 | return true; | 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 | return ( | 144 | return ( |
| 146 | left.length === right.length && left.every((projectId, index) => projectId === right[index]) | 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,11 +153,11 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 154 | ); | 153 | ); |
| 155 | currentProjectCwdById.clear(); | 154 | currentProjectCwdById.clear(); |
| 156 | for (const project of projects) { | 155 | for (const project of projects) { |
| 157 | currentProjectCwdById.set(project.id, project.cwd); | 156 | currentProjectCwdById.set(project.key, project.cwd); |
| 158 | } | 157 | } |
| 159 | const cwdMappingChanged = | 158 | const cwdMappingChanged = |
| 160 | previousProjectCwdById.size !== currentProjectCwdById.size || | 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 | const nextExpandedById: Record<string, boolean> = {}; | 162 | const nextExpandedById: Record<string, boolean> = {}; |
| 164 | const previousExpandedById = state.projectExpandedById; | 163 | const previousExpandedById = state.projectExpandedById; |
| ... | @@ -168,14 +167,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput | ... | @@ -168,14 +167,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 168 | const mappedProjects = projects.map((project, index) => { | 167 | const mappedProjects = projects.map((project, index) => { |
| 169 | const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); | 168 | const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); |
| 170 | const expanded = | 169 | const expanded = |
| 171 | previousExpandedById[project.id] ?? | 170 | previousExpandedById[project.key] ?? |
| 172 | (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? | 171 | (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? |
| 173 | (persistedExpandedProjectCwds.size > 0 | 172 | (persistedExpandedProjectCwds.size > 0 |
| 174 | ? persistedExpandedProjectCwds.has(project.cwd) | 173 | ? persistedExpandedProjectCwds.has(project.cwd) |
| 175 | : true); | 174 | : true); |
| 176 | nextExpandedById[project.id] = expanded; | 175 | nextExpandedById[project.key] = expanded; |
| 177 | return { | 176 | return { |
| 178 | id: project.id, | 177 | id: project.key, |
| 179 | cwd: project.cwd, | 178 | cwd: project.cwd, |
| 180 | incomingIndex: index, | 179 | incomingIndex: index, |
| 181 | }; | 180 | }; |
| ... | @@ -187,8 +186,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput | ... | @@ -187,8 +186,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 187 | const nextProjectIdByCwd = new Map( | 186 | const nextProjectIdByCwd = new Map( |
| 188 | mappedProjects.map((project) => [project.cwd, project.id] as const), | 187 | mappedProjects.map((project) => [project.cwd, project.id] as const), |
| 189 | ); | 188 | ); |
| 190 | const usedProjectIds = new Set<ProjectId>(); | 189 | const usedProjectIds = new Set<string>(); |
| 191 | const orderedProjectIds: ProjectId[] = []; | 190 | const orderedProjectIds: string[] = []; |
| 192 | 191 | ||
| 193 | for (const projectId of state.projectOrder) { | 192 | for (const projectId of state.projectOrder) { |
| 194 | const matchedProjectId = | 193 | const matchedProjectId = |
| ... | @@ -246,19 +245,19 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput | ... | @@ -246,19 +245,19 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput |
| 246 | } | 245 | } |
| 247 | 246 | ||
| 248 | export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { | 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 | const nextThreadLastVisitedAtById = Object.fromEntries( | 249 | const nextThreadLastVisitedAtById = Object.fromEntries( |
| 251 | Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => | 250 | Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => |
| 252 | retainedThreadIds.has(threadId as ThreadId), | 251 | retainedThreadIds.has(threadId), |
| 253 | ), | 252 | ), |
| 254 | ); | 253 | ); |
| 255 | for (const thread of threads) { | 254 | for (const thread of threads) { |
| 256 | if ( | 255 | if ( |
| 257 | nextThreadLastVisitedAtById[thread.id] === undefined && | 256 | nextThreadLastVisitedAtById[thread.key] === undefined && |
| 258 | thread.seedVisitedAt !== undefined && | 257 | thread.seedVisitedAt !== undefined && |
| 259 | thread.seedVisitedAt.length > 0 | 258 | thread.seedVisitedAt.length > 0 |
| 260 | ) { | 259 | ) { |
| 261 | nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt; | 260 | nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; |
| 262 | } | 261 | } |
| 263 | } | 262 | } |
| 264 | if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { | 263 | if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { |
| ... | @@ -270,7 +269,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) | ... | @@ -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 | const at = visitedAt ?? new Date().toISOString(); | 273 | const at = visitedAt ?? new Date().toISOString(); |
| 275 | const visitedAtMs = Date.parse(at); | 274 | const visitedAtMs = Date.parse(at); |
| 276 | const previousVisitedAt = state.threadLastVisitedAtById[threadId]; | 275 | const previousVisitedAt = state.threadLastVisitedAtById[threadId]; |
| ... | @@ -293,7 +292,7 @@ export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt? | ... | @@ -293,7 +292,7 @@ export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt? |
| 293 | 292 | ||
| 294 | export function markThreadUnread( | 293 | export function markThreadUnread( |
| 295 | state: UiState, | 294 | state: UiState, |
| 296 | threadId: ThreadId, | 295 | threadId: string, |
| 297 | latestTurnCompletedAt: string | null | undefined, | 296 | latestTurnCompletedAt: string | null | undefined, |
| 298 | ): UiState { | 297 | ): UiState { |
| 299 | if (!latestTurnCompletedAt) { | 298 | if (!latestTurnCompletedAt) { |
| ... | @@ -316,7 +315,7 @@ export function markThreadUnread( | ... | @@ -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 | if (!(threadId in state.threadLastVisitedAtById)) { | 319 | if (!(threadId in state.threadLastVisitedAtById)) { |
| 321 | return state; | 320 | return state; |
| 322 | } | 321 | } |
| ... | @@ -328,7 +327,7 @@ export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { | ... | @@ -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 | const expanded = state.projectExpandedById[projectId] ?? true; | 331 | const expanded = state.projectExpandedById[projectId] ?? true; |
| 333 | return { | 332 | return { |
| 334 | ...state, | 333 | ...state, |
| ... | @@ -339,11 +338,7 @@ export function toggleProject(state: UiState, projectId: ProjectId): UiState { | ... | @@ -339,11 +338,7 @@ export function toggleProject(state: UiState, projectId: ProjectId): UiState { |
| 339 | }; | 338 | }; |
| 340 | } | 339 | } |
| 341 | 340 | ||
| 342 | export function setProjectExpanded( | 341 | export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { |
| 343 | state: UiState, | ||
| 344 | projectId: ProjectId, | ||
| 345 | expanded: boolean, | ||
| 346 | ): UiState { | ||
| 347 | if ((state.projectExpandedById[projectId] ?? true) === expanded) { | 342 | if ((state.projectExpandedById[projectId] ?? true) === expanded) { |
| 348 | return state; | 343 | return state; |
| 349 | } | 344 | } |
| ... | @@ -358,8 +353,8 @@ export function setProjectExpanded( | ... | @@ -358,8 +353,8 @@ export function setProjectExpanded( |
| 358 | 353 | ||
| 359 | export function reorderProjects( | 354 | export function reorderProjects( |
| 360 | state: UiState, | 355 | state: UiState, |
| 361 | draggedProjectId: ProjectId, | 356 | draggedProjectId: string, |
| 362 | targetProjectId: ProjectId, | 357 | targetProjectId: string, |
| 363 | ): UiState { | 358 | ): UiState { |
| 364 | if (draggedProjectId === targetProjectId) { | 359 | if (draggedProjectId === targetProjectId) { |
| 365 | return state; | 360 | return state; |
| ... | @@ -384,12 +379,12 @@ export function reorderProjects( | ... | @@ -384,12 +379,12 @@ export function reorderProjects( |
| 384 | interface UiStateStore extends UiState { | 379 | interface UiStateStore extends UiState { |
| 385 | syncProjects: (projects: readonly SyncProjectInput[]) => void; | 380 | syncProjects: (projects: readonly SyncProjectInput[]) => void; |
| 386 | syncThreads: (threads: readonly SyncThreadInput[]) => void; | 381 | syncThreads: (threads: readonly SyncThreadInput[]) => void; |
| 387 | markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; | 382 | markThreadVisited: (threadId: string, visitedAt?: string) => void; |
| 388 | markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void; | 383 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; |
| 389 | clearThreadUi: (threadId: ThreadId) => void; | 384 | clearThreadUi: (threadId: string) => void; |
| 390 | toggleProject: (projectId: ProjectId) => void; | 385 | toggleProject: (projectId: string) => void; |
| 391 | setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; | 386 | setProjectExpanded: (projectId: string, expanded: boolean) => void; |
| 392 | reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void; | 387 | reorderProjects: (draggedProjectId: string, targetProjectId: string) => void; |
| 393 | } | 388 | } |
| 394 | 389 | ||
| 395 | export const useUiStateStore = create<UiStateStore>((set) => ({ | 390 | export const useUiStateStore = create<UiStateStore>((set) => ({ |
apps/web/src/vite-env.d.ts+2-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | /// <reference types="vite/client" /> | 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 | interface ImportMetaEnv { | 5 | interface ImportMetaEnv { |
| 6 | readonly APP_VERSION: string; | 6 | readonly APP_VERSION: string; |
| ... | @@ -12,7 +12,7 @@ interface ImportMeta { | ... | @@ -12,7 +12,7 @@ interface ImportMeta { |
| 12 | 12 | ||
| 13 | declare global { | 13 | declare global { |
| 14 | interface Window { | 14 | interface Window { |
| 15 | nativeApi?: NativeApi; | 15 | nativeApi?: LocalApi; |
| 16 | desktopBridge?: DesktopBridge; | 16 | desktopBridge?: DesktopBridge; |
| 17 | } | 17 | } |
| 18 | } | 18 | } |
apps/web/src/worktreeCleanup.test.ts+4-1| ... | @@ -1,12 +1,15 @@ | ... | @@ -1,12 +1,15 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; | 4 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| 5 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; | 5 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; |
| 6 | 6 | ||
| 7 | const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); | ||
| 8 | |||
| 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 9 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 8 | return { | 10 | return { |
| 9 | id: ThreadId.makeUnsafe("thread-1"), | 11 | id: ThreadId.makeUnsafe("thread-1"), |
| 12 | environmentId: localEnvironmentId, | ||
| 10 | codexThreadId: null, | 13 | codexThreadId: null, |
| 11 | projectId: ProjectId.makeUnsafe("project-1"), | 14 | projectId: ProjectId.makeUnsafe("project-1"), |
| 12 | title: "Thread", | 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,9 +3,25 @@ import type { |
| 3 | GitStatusRemoteResult, | 3 | GitStatusRemoteResult, |
| 4 | GitStatusStreamEvent, | 4 | GitStatusStreamEvent, |
| 5 | } from "@t3tools/contracts"; | 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 | import { type WsTransport } from "./wsTransport"; | 25 | import { type WsTransport } from "./wsTransport"; |
| 10 | 26 | ||
| 11 | const baseLocalStatus: GitStatusLocalResult = { | 27 | const baseLocalStatus: GitStatusLocalResult = { |
| ... | @@ -25,6 +41,10 @@ const baseRemoteStatus: GitStatusRemoteResult = { | ... | @@ -25,6 +41,10 @@ const baseRemoteStatus: GitStatusRemoteResult = { |
| 25 | }; | 41 | }; |
| 26 | 42 | ||
| 27 | describe("wsRpcClient", () => { | 43 | describe("wsRpcClient", () => { |
| 44 | afterEach(async () => { | ||
| 45 | await __resetWsRpcClientForTests(); | ||
| 46 | }); | ||
| 47 | |||
| 28 | it("reduces git status stream events into flat status snapshots", () => { | 48 | it("reduces git status stream events into flat status snapshots", () => { |
| 29 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { | 49 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { |
| 30 | for (const event of [ | 50 | for (const event of [ |
| ... | @@ -91,4 +111,20 @@ describe("wsRpcClient", () => { | ... | @@ -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,14 +4,20 @@ import { |
| 4 | type GitRunStackedActionResult, | 4 | type GitRunStackedActionResult, |
| 5 | type GitStatusResult, | 5 | type GitStatusResult, |
| 6 | type GitStatusStreamEvent, | 6 | type GitStatusStreamEvent, |
| 7 | type NativeApi, | 7 | type LocalApi, |
| 8 | ORCHESTRATION_WS_METHODS, | 8 | ORCHESTRATION_WS_METHODS, |
| 9 | type EnvironmentId, | ||
| 9 | type ServerSettingsPatch, | 10 | type ServerSettingsPatch, |
| 10 | WS_METHODS, | 11 | WS_METHODS, |
| 11 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { getKnownEnvironmentBaseUrl, type KnownEnvironment } from "@t3tools/client-runtime"; | ||
| 12 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | 14 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; |
| 13 | import { Effect, Stream } from "effect"; | 15 | import { Effect, Stream } from "effect"; |
| 14 | 16 | ||
| 17 | import { | ||
| 18 | getPrimaryKnownEnvironment, | ||
| 19 | resolvePrimaryEnvironmentBootstrapUrl, | ||
| 20 | } from "./environmentBootstrap"; | ||
| 15 | import { type WsRpcProtocolClient } from "./rpc/protocol"; | 21 | import { type WsRpcProtocolClient } from "./rpc/protocol"; |
| 16 | import { resetWsReconnectBackoff } from "./rpc/wsConnectionState"; | 22 | import { resetWsReconnectBackoff } from "./rpc/wsConnectionState"; |
| 17 | import { WsTransport } from "./wsTransport"; | 23 | import { WsTransport } from "./wsTransport"; |
| ... | @@ -61,9 +67,9 @@ export interface WsRpcClient { | ... | @@ -61,9 +67,9 @@ export interface WsRpcClient { |
| 61 | }; | 67 | }; |
| 62 | readonly shell: { | 68 | readonly shell: { |
| 63 | readonly openInEditor: (input: { | 69 | readonly openInEditor: (input: { |
| 64 | readonly cwd: Parameters<NativeApi["shell"]["openInEditor"]>[0]; | 70 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; |
| 65 | readonly editor: Parameters<NativeApi["shell"]["openInEditor"]>[1]; | 71 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; |
| 66 | }) => ReturnType<NativeApi["shell"]["openInEditor"]>; | 72 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; |
| 67 | }; | 73 | }; |
| 68 | readonly git: { | 74 | readonly git: { |
| 69 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; | 75 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; |
| ... | @@ -109,22 +115,157 @@ export interface WsRpcClient { | ... | @@ -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 | export function getWsRpcClient(): WsRpcClient { | 197 | export function getWsRpcClient(): WsRpcClient { |
| 115 | if (sharedWsRpcClient) { | 198 | return getPrimaryWsRpcClientEntry().client; |
| 116 | return sharedWsRpcClient; | 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(); | 254 | return entry.client; |
| 119 | return sharedWsRpcClient; | ||
| 120 | } | 255 | } |
| 121 | 256 | ||
| 122 | export async function __resetWsRpcClientForTests() { | 257 | export async function __resetWsRpcClientForTests() { |
| 123 | await sharedWsRpcClient?.dispose(); | 258 | for (const entry of wsRpcClientEntriesByKey.values()) { |
| 124 | sharedWsRpcClient = null; | 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 | return { | 269 | return { |
| 129 | dispose: () => transport.dispose(), | 270 | dispose: () => transport.dispose(), |
| 130 | reconnect: async () => { | 271 | reconnect: async () => { |
apps/web/src/wsTransport.test.ts+21| ... | @@ -436,6 +436,13 @@ describe("WsTransport", () => { | ... | @@ -436,6 +436,13 @@ describe("WsTransport", () => { |
| 436 | sequence: 1, | 436 | sequence: 1, |
| 437 | type: "welcome", | 437 | type: "welcome", |
| 438 | payload: { | 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 | cwd: "/tmp/workspace", | 446 | cwd: "/tmp/workspace", |
| 440 | projectName: "workspace", | 447 | projectName: "workspace", |
| 441 | }, | 448 | }, |
| ... | @@ -489,6 +496,13 @@ describe("WsTransport", () => { | ... | @@ -489,6 +496,13 @@ describe("WsTransport", () => { |
| 489 | sequence: 1, | 496 | sequence: 1, |
| 490 | type: "welcome", | 497 | type: "welcome", |
| 491 | payload: { | 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 | cwd: "/tmp/one", | 506 | cwd: "/tmp/one", |
| 493 | projectName: "one", | 507 | projectName: "one", |
| 494 | }, | 508 | }, |
| ... | @@ -532,6 +546,13 @@ describe("WsTransport", () => { | ... | @@ -532,6 +546,13 @@ describe("WsTransport", () => { |
| 532 | sequence: 2, | 546 | sequence: 2, |
| 533 | type: "welcome", | 547 | type: "welcome", |
| 534 | payload: { | 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 | cwd: "/tmp/two", | 556 | cwd: "/tmp/two", |
| 536 | projectName: "two", | 557 | projectName: "two", |
| 537 | }, | 558 | }, |
bun.lock+15| ... | @@ -82,6 +82,7 @@ | ... | @@ -82,6 +82,7 @@ |
| 82 | "@formkit/auto-animate": "^0.9.0", | 82 | "@formkit/auto-animate": "^0.9.0", |
| 83 | "@lexical/react": "^0.41.0", | 83 | "@lexical/react": "^0.41.0", |
| 84 | "@pierre/diffs": "^1.1.0-beta.16", | 84 | "@pierre/diffs": "^1.1.0-beta.16", |
| 85 | "@t3tools/client-runtime": "workspace:*", | ||
| 85 | "@t3tools/contracts": "workspace:*", | 86 | "@t3tools/contracts": "workspace:*", |
| 86 | "@t3tools/shared": "workspace:*", | 87 | "@t3tools/shared": "workspace:*", |
| 87 | "@tanstack/react-pacer": "^0.19.4", | 88 | "@tanstack/react-pacer": "^0.19.4", |
| ... | @@ -121,6 +122,18 @@ | ... | @@ -121,6 +122,18 @@ |
| 121 | "vitest-browser-react": "^2.0.5", | 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 | "packages/contracts": { | 137 | "packages/contracts": { |
| 125 | "name": "@t3tools/contracts", | 138 | "name": "@t3tools/contracts", |
| 126 | "version": "0.0.15", | 139 | "version": "0.0.15", |
| ... | @@ -659,6 +672,8 @@ | ... | @@ -659,6 +672,8 @@ |
| 659 | 672 | ||
| 660 | "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], | 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 | "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"], | 677 | "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"], |
| 663 | 678 | ||
| 664 | "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], | 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,6 +19,8 @@ export const ThreadId = makeEntityId("ThreadId"); |
| 19 | export type ThreadId = typeof ThreadId.Type; | 19 | export type ThreadId = typeof ThreadId.Type; |
| 20 | export const ProjectId = makeEntityId("ProjectId"); | 20 | export const ProjectId = makeEntityId("ProjectId"); |
| 21 | export type ProjectId = typeof ProjectId.Type; | 21 | export type ProjectId = typeof ProjectId.Type; |
| 22 | export const EnvironmentId = makeEntityId("EnvironmentId"); | ||
| 23 | export type EnvironmentId = typeof EnvironmentId.Type; | ||
| 22 | export const CommandId = makeEntityId("CommandId"); | 24 | export const CommandId = makeEntityId("CommandId"); |
| 23 | export type CommandId = typeof CommandId.Type; | 25 | export type CommandId = typeof CommandId.Type; |
| 24 | export const EventId = makeEntityId("EventId"); | 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,4 +1,5 @@ |
| 1 | export * from "./baseSchemas"; | 1 | export * from "./baseSchemas"; |
| 2 | export * from "./environment"; | ||
| 2 | export * from "./ipc"; | 3 | export * from "./ipc"; |
| 3 | export * from "./terminal"; | 4 | export * from "./terminal"; |
| 4 | export * from "./provider"; | 5 | export * from "./provider"; |
packages/contracts/src/ipc.ts+46-20| ... | @@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult { | ... | @@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult { |
| 105 | state: DesktopUpdateState; | 105 | state: DesktopUpdateState; |
| 106 | } | 106 | } |
| 107 | 107 | ||
| 108 | export interface DesktopEnvironmentBootstrap { | ||
| 109 | label: string; | ||
| 110 | wsUrl: string | null; | ||
| 111 | } | ||
| 112 | |||
| 108 | export interface DesktopBridge { | 113 | export interface DesktopBridge { |
| 109 | getWsUrl: () => string | null; | 114 | getWsUrl: () => string | null; |
| 115 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | ||
| 110 | pickFolder: () => Promise<string | null>; | 116 | pickFolder: () => Promise<string | null>; |
| 111 | confirm: (message: string) => Promise<boolean>; | 117 | confirm: (message: string) => Promise<boolean>; |
| 112 | setTheme: (theme: DesktopTheme) => Promise<void>; | 118 | setTheme: (theme: DesktopTheme) => Promise<void>; |
| ... | @@ -123,11 +129,50 @@ export interface DesktopBridge { | ... | @@ -123,11 +129,50 @@ export interface DesktopBridge { |
| 123 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; | 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 | dialogs: { | 143 | dialogs: { |
| 128 | pickFolder: () => Promise<string | null>; | 144 | pickFolder: () => Promise<string | null>; |
| 129 | confirm: (message: string) => Promise<boolean>; | 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 | terminal: { | 176 | terminal: { |
| 132 | open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>; | 177 | open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>; |
| 133 | write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>; | 178 | write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>; |
| ... | @@ -141,12 +186,7 @@ export interface NativeApi { | ... | @@ -141,12 +186,7 @@ export interface NativeApi { |
| 141 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; | 186 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| 142 | writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>; | 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 | git: { | 189 | git: { |
| 149 | // Existing branch/worktree API | ||
| 150 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; | 190 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; |
| 151 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; | 191 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; |
| 152 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; | 192 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; |
| ... | @@ -157,7 +197,6 @@ export interface NativeApi { | ... | @@ -157,7 +197,6 @@ export interface NativeApi { |
| 157 | preparePullRequestThread: ( | 197 | preparePullRequestThread: ( |
| 158 | input: GitPreparePullRequestThreadInput, | 198 | input: GitPreparePullRequestThreadInput, |
| 159 | ) => Promise<GitPreparePullRequestThreadResult>; | 199 | ) => Promise<GitPreparePullRequestThreadResult>; |
| 160 | // Stacked action API | ||
| 161 | pull: (input: GitPullInput) => Promise<GitPullResult>; | 200 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 162 | refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>; | 201 | refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 163 | onStatus: ( | 202 | onStatus: ( |
| ... | @@ -168,19 +207,6 @@ export interface NativeApi { | ... | @@ -168,19 +207,6 @@ export interface NativeApi { |
| 168 | }, | 207 | }, |
| 169 | ) => () => void; | 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 | orchestration: { | 210 | orchestration: { |
| 185 | getSnapshot: () => Promise<OrchestrationReadModel>; | 211 | getSnapshot: () => Promise<OrchestrationReadModel>; |
| 186 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; | 212 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; |
packages/contracts/src/orchestration.ts+4| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { Option, Schema, SchemaIssue, Struct } from "effect"; | 1 | import { Option, Schema, SchemaIssue, Struct } from "effect"; |
| 2 | import { ClaudeModelOptions, CodexModelOptions } from "./model"; | 2 | import { ClaudeModelOptions, CodexModelOptions } from "./model"; |
| 3 | import { RepositoryIdentity } from "./environment"; | ||
| 3 | import { | 4 | import { |
| 4 | ApprovalRequestId, | 5 | ApprovalRequestId, |
| 5 | CheckpointRef, | 6 | CheckpointRef, |
| ... | @@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({ | ... | @@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({ |
| 141 | id: ProjectId, | 142 | id: ProjectId, |
| 142 | title: TrimmedNonEmptyString, | 143 | title: TrimmedNonEmptyString, |
| 143 | workspaceRoot: TrimmedNonEmptyString, | 144 | workspaceRoot: TrimmedNonEmptyString, |
| 145 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | ||
| 144 | defaultModelSelection: Schema.NullOr(ModelSelection), | 146 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 145 | scripts: Schema.Array(ProjectScript), | 147 | scripts: Schema.Array(ProjectScript), |
| 146 | createdAt: IsoDateTime, | 148 | createdAt: IsoDateTime, |
| ... | @@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({ | ... | @@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({ |
| 647 | projectId: ProjectId, | 649 | projectId: ProjectId, |
| 648 | title: TrimmedNonEmptyString, | 650 | title: TrimmedNonEmptyString, |
| 649 | workspaceRoot: TrimmedNonEmptyString, | 651 | workspaceRoot: TrimmedNonEmptyString, |
| 652 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | ||
| 650 | defaultModelSelection: Schema.NullOr(ModelSelection), | 653 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 651 | scripts: Schema.Array(ProjectScript), | 654 | scripts: Schema.Array(ProjectScript), |
| 652 | createdAt: IsoDateTime, | 655 | createdAt: IsoDateTime, |
| ... | @@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ | ... | @@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ |
| 657 | projectId: ProjectId, | 660 | projectId: ProjectId, |
| 658 | title: Schema.optional(TrimmedNonEmptyString), | 661 | title: Schema.optional(TrimmedNonEmptyString), |
| 659 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), | 662 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), |
| 663 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | ||
| 660 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 664 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 661 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 665 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 662 | updatedAt: IsoDateTime, | 666 | updatedAt: IsoDateTime, |
packages/contracts/src/server.ts+4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { ExecutionEnvironmentDescriptor } from "./environment"; | ||
| 2 | import { | 3 | import { |
| 3 | IsoDateTime, | 4 | IsoDateTime, |
| 4 | NonNegativeInt, | 5 | NonNegativeInt, |
| ... | @@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({ | ... | @@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({ |
| 83 | export type ServerObservability = typeof ServerObservability.Type; | 84 | export type ServerObservability = typeof ServerObservability.Type; |
| 84 | 85 | ||
| 85 | export const ServerConfig = Schema.Struct({ | 86 | export const ServerConfig = Schema.Struct({ |
| 87 | environment: ExecutionEnvironmentDescriptor, | ||
| 86 | cwd: TrimmedNonEmptyString, | 88 | cwd: TrimmedNonEmptyString, |
| 87 | keybindingsConfigPath: TrimmedNonEmptyString, | 89 | keybindingsConfigPath: TrimmedNonEmptyString, |
| 88 | keybindings: ResolvedKeybindingsConfig, | 90 | keybindings: ResolvedKeybindingsConfig, |
| ... | @@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; | ... | @@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; |
| 167 | 169 | ||
| 168 | export const ServerLifecycleReadyPayload = Schema.Struct({ | 170 | export const ServerLifecycleReadyPayload = Schema.Struct({ |
| 169 | at: IsoDateTime, | 171 | at: IsoDateTime, |
| 172 | environment: ExecutionEnvironmentDescriptor, | ||
| 170 | }); | 173 | }); |
| 171 | export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; | 174 | export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; |
| 172 | 175 | ||
| 173 | export const ServerLifecycleWelcomePayload = Schema.Struct({ | 176 | export const ServerLifecycleWelcomePayload = Schema.Struct({ |
| 177 | environment: ExecutionEnvironmentDescriptor, | ||
| 174 | cwd: TrimmedNonEmptyString, | 178 | cwd: TrimmedNonEmptyString, |
| 175 | projectName: TrimmedNonEmptyString, | 179 | projectName: TrimmedNonEmptyString, |
| 176 | bootstrapProjectId: Schema.optional(ProjectId), | 180 | bootstrapProjectId: Schema.optional(ProjectId), |
packages/shared/src/git.test.ts+48-1| ... | @@ -1,7 +1,54 @@ | ... | @@ -1,7 +1,54 @@ |
| 1 | import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; | 1 | import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 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 | describe("applyGitStatusStreamEvent", () => { | 53 | describe("applyGitStatusStreamEvent", () => { |
| 7 | it("treats a remote-only update as a repository when local state is missing", () => { | 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,6 +80,56 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { |
| 80 | return branchName.slice(firstSeparatorIndex + 1); | 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 | function deriveLocalBranchNameCandidatesFromRemoteRef( | 133 | function deriveLocalBranchNameCandidatesFromRemoteRef( |
| 84 | branchName: string, | 134 | branchName: string, |
| 85 | remoteName?: string, | 135 | remoteName?: string, |
scripts/release-smoke.ts+1| ... | @@ -13,6 +13,7 @@ const workspaceFiles = [ | ... | @@ -13,6 +13,7 @@ const workspaceFiles = [ |
| 13 | "apps/desktop/package.json", | 13 | "apps/desktop/package.json", |
| 14 | "apps/web/package.json", | 14 | "apps/web/package.json", |
| 15 | "apps/marketing/package.json", | 15 | "apps/marketing/package.json", |
| 16 | "packages/client-runtime/package.json", | ||
| 16 | "packages/contracts/package.json", | 17 | "packages/contracts/package.json", |
| 17 | "packages/shared/package.json", | 18 | "packages/shared/package.json", |
| 18 | "scripts/package.json", | 19 | "scripts/package.json", |