Unified · Split
| @@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; · 59 unmodified lines | |||
| 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 { · 1106 unmodified lines | |||
| 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; |
| @@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; · 12 unmodified lines | |||
| 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), |
| @@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; · 44 unmodified lines | |||
| 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 = ( · 287 unmodified lines | |||
| 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), |
| @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { · 29 unmodified lines | |||
| 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* ( · 47 unmodified lines | |||
| 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 | ||
| @@ -0,0 +1,124 @@ | |||
| 1 | import * as nodePath from "node:path"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { expect, it } from "@effect/vitest"; | ||
| 4 | import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; | ||
| 5 | |||
| 6 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts"; | ||
| 7 | import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; | ||
| 8 | import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; | ||
| 9 | |||
| 10 | const makeServerEnvironmentLayer = (baseDir: string) => | ||
| 11 | ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); | ||
| 12 | |||
| 13 | const makeServerConfig = Effect.fn(function* (baseDir: string) { | ||
| 14 | const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | ||
| 15 | |||
| 16 | return { | ||
| 17 | ...derivedPaths, | ||
| 18 | logLevel: "Error", | ||
| 19 | traceMinLevel: "Info", | ||
| 20 | traceTimingEnabled: true, | ||
| 21 | traceBatchWindowMs: 200, | ||
| 22 | traceMaxBytes: 10 * 1024 * 1024, | ||
| 23 | traceMaxFiles: 10, | ||
| 24 | otlpTracesUrl: undefined, | ||
| 25 | otlpMetricsUrl: undefined, | ||
| 26 | otlpExportIntervalMs: 10_000, | ||
| 27 | otlpServiceName: "t3-server", | ||
| 28 | cwd: process.cwd(), | ||
| 29 | baseDir, | ||
| 30 | mode: "web", | ||
| 31 | autoBootstrapProjectFromCwd: false, | ||
| 32 | logWebSocketEvents: false, | ||
| 33 | port: 0, | ||
| 34 | host: undefined, | ||
| 35 | authToken: undefined, | ||
| 36 | staticDir: undefined, | ||
| 37 | devUrl: undefined, | ||
| 38 | noBrowser: false, | ||
| 39 | } satisfies ServerConfigShape; | ||
| 40 | }); | ||
| 41 | |||
| 42 | it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | ||
| 43 | it.effect("persists the environment id across service restarts", () => | ||
| 44 | Effect.gen(function* () { | ||
| 45 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 46 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 47 | prefix: "t3-server-environment-test-", | ||
| 48 | }); | ||
| 49 | |||
| 50 | const first = yield* Effect.gen(function* () { | ||
| 51 | const serverEnvironment = yield* ServerEnvironment; | ||
| 52 | return yield* serverEnvironment.getDescriptor; | ||
| 53 | }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | ||
| 54 | const second = yield* Effect.gen(function* () { | ||
| 55 | const serverEnvironment = yield* ServerEnvironment; | ||
| 56 | return yield* serverEnvironment.getDescriptor; | ||
| 57 | }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | ||
| 58 | |||
| 59 | expect(first.environmentId).toBe(second.environmentId); | ||
| 60 | expect(second.capabilities.repositoryIdentity).toBe(true); | ||
| 61 | }), | ||
| 62 | ); | ||
| 63 | |||
| 64 | it.effect("fails instead of overwriting a persisted id when reading the file errors", () => | ||
| 65 | Effect.gen(function* () { | ||
| 66 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 67 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 68 | prefix: "t3-server-environment-read-error-test-", | ||
| 69 | }); | ||
| 70 | const serverConfig = yield* makeServerConfig(baseDir); | ||
| 71 | const environmentIdPath = serverConfig.environmentIdPath; | ||
| 72 | yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); | ||
| 73 | yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); | ||
| 74 | const writeAttempts: string[] = []; | ||
| 75 | const failingFileSystemLayer = FileSystem.layerNoop({ | ||
| 76 | exists: (path) => Effect.succeed(path === environmentIdPath), | ||
| 77 | readFileString: (path) => | ||
| 78 | path === environmentIdPath | ||
| 79 | ? Effect.fail( | ||
| 80 | PlatformError.systemError({ | ||
| 81 | _tag: "PermissionDenied", | ||
| 82 | module: "FileSystem", | ||
| 83 | method: "readFileString", | ||
| 84 | description: "permission denied", | ||
| 85 | pathOrDescriptor: path, | ||
| 86 | }), | ||
| 87 | ) | ||
| 88 | : Effect.fail( | ||
| 89 | PlatformError.systemError({ | ||
| 90 | _tag: "NotFound", | ||
| 91 | module: "FileSystem", | ||
| 92 | method: "readFileString", | ||
| 93 | description: "not found", | ||
| 94 | pathOrDescriptor: path, | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | writeFileString: (path) => { | ||
| 98 | writeAttempts.push(path); | ||
| 99 | return Effect.void; | ||
| 100 | }, | ||
| 101 | }); | ||
| 102 | |||
| 103 | const exit = yield* Effect.gen(function* () { | ||
| 104 | const serverEnvironment = yield* ServerEnvironment; | ||
| 105 | return yield* serverEnvironment.getDescriptor; | ||
| 106 | }).pipe( | ||
| 107 | Effect.provide( | ||
| 108 | ServerEnvironmentLive.pipe( | ||
| 109 | Layer.provide( | ||
| 110 | Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), | ||
| 111 | ), | ||
| 112 | ), | ||
| 113 | ), | ||
| 114 | Effect.exit, | ||
| 115 | ); | ||
| 116 | |||
| 117 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 118 | expect(writeAttempts).toEqual([]); | ||
| 119 | expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( | ||
| 120 | "persisted-environment-id\n", | ||
| 121 | ); | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | }); | ||
| @@ -0,0 +1,94 @@ | |||
| 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | ||
| 2 | import { Effect, FileSystem, Layer, Path, Random } from "effect"; | ||
| 3 | |||
| 4 | import { ServerConfig } from "../../config.ts"; | ||
| 5 | import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; | ||
| 6 | import { version } from "../../../package.json" with { type: "json" }; | ||
| 7 | |||
| 8 | function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { | ||
| 9 | switch (process.platform) { | ||
| 10 | case "darwin": | ||
| 11 | return "darwin"; | ||
| 12 | case "linux": | ||
| 13 | return "linux"; | ||
| 14 | case "win32": | ||
| 15 | return "windows"; | ||
| 16 | default: | ||
| 17 | return "unknown"; | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { | ||
| 22 | switch (process.arch) { | ||
| 23 | case "arm64": | ||
| 24 | return "arm64"; | ||
| 25 | case "x64": | ||
| 26 | return "x64"; | ||
| 27 | default: | ||
| 28 | return "other"; | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { | ||
| 33 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 34 | const path = yield* Path.Path; | ||
| 35 | const serverConfig = yield* ServerConfig; | ||
| 36 | |||
| 37 | const readPersistedEnvironmentId = Effect.gen(function* () { | ||
| 38 | const exists = yield* fileSystem | ||
| 39 | .exists(serverConfig.environmentIdPath) | ||
| 40 | .pipe(Effect.orElseSucceed(() => false)); | ||
| 41 | if (!exists) { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | |||
| 45 | const raw = yield* fileSystem | ||
| 46 | .readFileString(serverConfig.environmentIdPath) | ||
| 47 | .pipe(Effect.map((value) => value.trim())); | ||
| 48 | |||
| 49 | return raw.length > 0 ? raw : null; | ||
| 50 | }); | ||
| 51 | |||
| 52 | const persistEnvironmentId = (value: string) => | ||
| 53 | fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); | ||
| 54 | |||
| 55 | const environmentIdRaw = yield* Effect.gen(function* () { | ||
| 56 | const persisted = yield* readPersistedEnvironmentId; | ||
| 57 | if (persisted) { | ||
| 58 | return persisted; | ||
| 59 | } | ||
| 60 | |||
| 61 | const generated = yield* Random.nextUUIDv4; | ||
| 62 | yield* persistEnvironmentId(generated); | ||
| 63 | return generated; | ||
| 64 | }); | ||
| 65 | |||
| 66 | const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); | ||
| 67 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); | ||
| 68 | const label = | ||
| 69 | serverConfig.mode === "desktop" | ||
| 70 | ? "Local environment" | ||
| 71 | : cwdBaseName.length > 0 | ||
| 72 | ? cwdBaseName | ||
| 73 | : "T3 environment"; | ||
| 74 | |||
| 75 | const descriptor: ExecutionEnvironmentDescriptor = { | ||
| 76 | environmentId, | ||
| 77 | label, | ||
| 78 | platform: { | ||
| 79 | os: platformOs(), | ||
| 80 | arch: platformArch(), | ||
| 81 | }, | ||
| 82 | serverVersion: version, | ||
| 83 | capabilities: { | ||
| 84 | repositoryIdentity: true, | ||
| 85 | }, | ||
| 86 | }; | ||
| 87 | |||
| 88 | return { | ||
| 89 | getEnvironmentId: Effect.succeed(environmentId), | ||
| 90 | getDescriptor: Effect.succeed(descriptor), | ||
| 91 | } satisfies ServerEnvironmentShape; | ||
| 92 | }); | ||
| 93 | |||
| 94 | export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | ||
| 2 | import { ServiceMap } from "effect"; | ||
| 3 | import type { Effect } from "effect"; | ||
| 4 | |||
| 5 | export interface ServerEnvironmentShape { | ||
| 6 | readonly getEnvironmentId: Effect.Effect<EnvironmentId>; | ||
| 7 | readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>; | ||
| 8 | } | ||
| 9 | |||
| 10 | export class ServerEnvironment extends ServiceMap.Service< | ||
| 11 | ServerEnvironment, | ||
| 12 | ServerEnvironmentShape | ||
| 13 | >()("t3/environment/Services/ServerEnvironment") {} | ||
| @@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 853 unmodified lines | |||
| 854 | "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) => { · 101 unmodified lines | |||
| 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) => { · 716 unmodified lines | |||
| 1685 | false, | 1685 | false, |
| 1686 | ); | 1686 | ); |
| 1687 | }), | 1687 | }), |
| 1688 | 12_000, | 1688 | 20_000, |
| 1689 | ); | 1689 | ); |
| 1690 | 1690 | ||
| 1691 | it.effect( | 1691 | it.effect( |
| @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines | |||
| 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", () => { · 230 unmodified lines | |||
| 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 | ||
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | |||
| 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() { · 20 unmodified lines | |||
| 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", () => { · 572 unmodified lines | |||
| 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", () => { · 90 unmodified lines | |||
| 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", () => { · 136 unmodified lines | |||
| 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 | ); |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | |||
| 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( · 1820 unmodified lines | |||
| 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(), { |
| @@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines | |||
| 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); · 5 unmodified lines | |||
| 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) => { · 212 unmodified lines | |||
| 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", |
| @@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh · 37 unmodified lines | |||
| 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 · 119 unmodified lines | |||
| 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* () { · 267 unmodified lines | |||
| 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* () { · 27 unmodified lines | |||
| 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 | ||
| @@ -28,6 +28,7 @@ import { · 27 unmodified lines | |||
| 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", () => { · 184 unmodified lines | |||
| 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( |
| @@ -29,6 +29,7 @@ import { · 28 unmodified lines | |||
| 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", () => { · 169 unmodified lines | |||
| 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( |
| @@ -0,0 +1,193 @@ | |||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { Duration, Effect, FileSystem, Layer } from "effect"; | ||
| 4 | import { TestClock } from "effect/testing"; | ||
| 5 | |||
| 6 | import { runProcess } from "../../processRunner.ts"; | ||
| 7 | import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; | ||
| 8 | import { | ||
| 9 | makeRepositoryIdentityResolver, | ||
| 10 | RepositoryIdentityResolverLive, | ||
| 11 | } from "./RepositoryIdentityResolver.ts"; | ||
| 12 | |||
| 13 | const git = (cwd: string, args: ReadonlyArray<string>) => | ||
| 14 | Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); | ||
| 15 | |||
| 16 | const makeRepositoryIdentityResolverTestLayer = (options: { | ||
| 17 | readonly positiveCacheTtl?: Duration.Input; | ||
| 18 | readonly negativeCacheTtl?: Duration.Input; | ||
| 19 | }) => | ||
| 20 | Layer.effect( | ||
| 21 | RepositoryIdentityResolver, | ||
| 22 | makeRepositoryIdentityResolver({ | ||
| 23 | cacheCapacity: 16, | ||
| 24 | ...options, | ||
| 25 | }), | ||
| 26 | ); | ||
| 27 | |||
| 28 | it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | ||
| 29 | it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => | ||
| 30 | Effect.gen(function* () { | ||
| 31 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 32 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 33 | prefix: "t3-repository-identity-test-", | ||
| 34 | }); | ||
| 35 | |||
| 36 | yield* git(cwd, ["init"]); | ||
| 37 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 38 | |||
| 39 | const resolver = yield* RepositoryIdentityResolver; | ||
| 40 | const identity = yield* resolver.resolve(cwd); | ||
| 41 | |||
| 42 | expect(identity).not.toBeNull(); | ||
| 43 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 44 | expect(identity?.displayName).toBe("t3tools/t3code"); | ||
| 45 | expect(identity?.provider).toBe("github"); | ||
| 46 | expect(identity?.owner).toBe("t3tools"); | ||
| 47 | expect(identity?.name).toBe("t3code"); | ||
| 48 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 49 | ); | ||
| 50 | |||
| 51 | it.effect("returns null for non-git folders and repos without remotes", () => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 54 | const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 55 | prefix: "t3-repository-identity-non-git-", | ||
| 56 | }); | ||
| 57 | const gitDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 58 | prefix: "t3-repository-identity-no-remote-", | ||
| 59 | }); | ||
| 60 | |||
| 61 | yield* git(gitDir, ["init"]); | ||
| 62 | |||
| 63 | const resolver = yield* RepositoryIdentityResolver; | ||
| 64 | const nonGitIdentity = yield* resolver.resolve(nonGitDir); | ||
| 65 | const noRemoteIdentity = yield* resolver.resolve(gitDir); | ||
| 66 | |||
| 67 | expect(nonGitIdentity).toBeNull(); | ||
| 68 | expect(noRemoteIdentity).toBeNull(); | ||
| 69 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 70 | ); | ||
| 71 | |||
| 72 | it.effect("prefers upstream over origin when both remotes are configured", () => | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 75 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 76 | prefix: "t3-repository-identity-upstream-test-", | ||
| 77 | }); | ||
| 78 | |||
| 79 | yield* git(cwd, ["init"]); | ||
| 80 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); | ||
| 81 | yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); | ||
| 82 | |||
| 83 | const resolver = yield* RepositoryIdentityResolver; | ||
| 84 | const identity = yield* resolver.resolve(cwd); | ||
| 85 | |||
| 86 | expect(identity).not.toBeNull(); | ||
| 87 | expect(identity?.locator.remoteName).toBe("upstream"); | ||
| 88 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 89 | expect(identity?.displayName).toBe("t3tools/t3code"); | ||
| 90 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 91 | ); | ||
| 92 | |||
| 93 | it.effect("uses the last remote path segment as the repository name for nested groups", () => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 96 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 97 | prefix: "t3-repository-identity-nested-group-test-", | ||
| 98 | }); | ||
| 99 | |||
| 100 | yield* git(cwd, ["init"]); | ||
| 101 | yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); | ||
| 102 | |||
| 103 | const resolver = yield* RepositoryIdentityResolver; | ||
| 104 | const identity = yield* resolver.resolve(cwd); | ||
| 105 | |||
| 106 | expect(identity).not.toBeNull(); | ||
| 107 | expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); | ||
| 108 | expect(identity?.displayName).toBe("t3tools/platform/t3code"); | ||
| 109 | expect(identity?.owner).toBe("t3tools"); | ||
| 110 | expect(identity?.name).toBe("t3code"); | ||
| 111 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 112 | ); | ||
| 113 | |||
| 114 | it.effect( | ||
| 115 | "refreshes cached null identities after the negative TTL when a remote is configured later", | ||
| 116 | () => | ||
| 117 | Effect.gen(function* () { | ||
| 118 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 119 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 120 | prefix: "t3-repository-identity-late-remote-test-", | ||
| 121 | }); | ||
| 122 | |||
| 123 | yield* git(cwd, ["init"]); | ||
| 124 | |||
| 125 | const resolver = yield* RepositoryIdentityResolver; | ||
| 126 | const initialIdentity = yield* resolver.resolve(cwd); | ||
| 127 | expect(initialIdentity).toBeNull(); | ||
| 128 | |||
| 129 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 130 | |||
| 131 | const cachedIdentity = yield* resolver.resolve(cwd); | ||
| 132 | expect(cachedIdentity).toBeNull(); | ||
| 133 | |||
| 134 | yield* TestClock.adjust(Duration.millis(120)); | ||
| 135 | |||
| 136 | const refreshedIdentity = yield* resolver.resolve(cwd); | ||
| 137 | expect(refreshedIdentity).not.toBeNull(); | ||
| 138 | expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 139 | expect(refreshedIdentity?.name).toBe("t3code"); | ||
| 140 | }).pipe( | ||
| 141 | Effect.provide( | ||
| 142 | Layer.merge( | ||
| 143 | TestClock.layer(), | ||
| 144 | makeRepositoryIdentityResolverTestLayer({ | ||
| 145 | negativeCacheTtl: Duration.millis(50), | ||
| 146 | positiveCacheTtl: Duration.seconds(1), | ||
| 147 | }), | ||
| 148 | ), | ||
| 149 | ), | ||
| 150 | ), | ||
| 151 | ); | ||
| 152 | |||
| 153 | it.effect("refreshes cached identities after the positive TTL when a remote changes", () => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 156 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 157 | prefix: "t3-repository-identity-remote-change-test-", | ||
| 158 | }); | ||
| 159 | |||
| 160 | yield* git(cwd, ["init"]); | ||
| 161 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 162 | |||
| 163 | const resolver = yield* RepositoryIdentityResolver; | ||
| 164 | const initialIdentity = yield* resolver.resolve(cwd); | ||
| 165 | expect(initialIdentity).not.toBeNull(); | ||
| 166 | expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 167 | |||
| 168 | yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); | ||
| 169 | |||
| 170 | const cachedIdentity = yield* resolver.resolve(cwd); | ||
| 171 | expect(cachedIdentity).not.toBeNull(); | ||
| 172 | expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 173 | |||
| 174 | yield* TestClock.adjust(Duration.millis(180)); | ||
| 175 | |||
| 176 | const refreshedIdentity = yield* resolver.resolve(cwd); | ||
| 177 | expect(refreshedIdentity).not.toBeNull(); | ||
| 178 | expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); | ||
| 179 | expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); | ||
| 180 | expect(refreshedIdentity?.name).toBe("t3code-next"); | ||
| 181 | }).pipe( | ||
| 182 | Effect.provide( | ||
| 183 | Layer.merge( | ||
| 184 | TestClock.layer(), | ||
| 185 | makeRepositoryIdentityResolverTestLayer({ | ||
| 186 | negativeCacheTtl: Duration.millis(50), | ||
| 187 | positiveCacheTtl: Duration.millis(100), | ||
| 188 | }), | ||
| 189 | ), | ||
| 190 | ), | ||
| 191 | ), | ||
| 192 | ); | ||
| 193 | }); | ||
| @@ -0,0 +1,147 @@ | |||
| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; | ||
| 2 | import { Cache, Duration, Effect, Exit, Layer } from "effect"; | ||
| 3 | import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; | ||
| 4 | |||
| 5 | import { runProcess } from "../../processRunner.ts"; | ||
| 6 | import { | ||
| 7 | RepositoryIdentityResolver, | ||
| 8 | type RepositoryIdentityResolverShape, | ||
| 9 | } from "../Services/RepositoryIdentityResolver.ts"; | ||
| 10 | |||
| 11 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { | ||
| 12 | const remotes = new Map<string, string>(); | ||
| 13 | for (const line of stdout.split("\n")) { | ||
| 14 | const trimmed = line.trim(); | ||
| 15 | if (trimmed.length === 0) continue; | ||
| 16 | const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); | ||
| 17 | if (!match) continue; | ||
| 18 | const [, remoteName = "", remoteUrl = "", direction = ""] = match; | ||
| 19 | if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { | ||
| 20 | continue; | ||
| 21 | } | ||
| 22 | remotes.set(remoteName, remoteUrl); | ||
| 23 | } | ||
| 24 | return remotes; | ||
| 25 | } | ||
| 26 | |||
| 27 | function pickPrimaryRemote( | ||
| 28 | remotes: ReadonlyMap<string, string>, | ||
| 29 | ): { readonly remoteName: string; readonly remoteUrl: string } | null { | ||
| 30 | for (const preferredRemoteName of ["upstream", "origin"] as const) { | ||
| 31 | const remoteUrl = remotes.get(preferredRemoteName); | ||
| 32 | if (remoteUrl) { | ||
| 33 | return { remoteName: preferredRemoteName, remoteUrl }; | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | const [remoteName, remoteUrl] = | ||
| 38 | [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; | ||
| 39 | return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; | ||
| 40 | } | ||
| 41 | |||
| 42 | function buildRepositoryIdentity(input: { | ||
| 43 | readonly remoteName: string; | ||
| 44 | readonly remoteUrl: string; | ||
| 45 | }): RepositoryIdentity { | ||
| 46 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); | ||
| 47 | const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); | ||
| 48 | const repositoryPath = canonicalKey.split("/").slice(1).join("/"); | ||
| 49 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); | ||
| 50 | const [owner] = repositoryPathSegments; | ||
| 51 | const repositoryName = repositoryPathSegments.at(-1); | ||
| 52 | |||
| 53 | return { | ||
| 54 | canonicalKey, | ||
| 55 | locator: { | ||
| 56 | source: "git-remote", | ||
| 57 | remoteName: input.remoteName, | ||
| 58 | remoteUrl: input.remoteUrl, | ||
| 59 | }, | ||
| 60 | ...(repositoryPath ? { displayName: repositoryPath } : {}), | ||
| 61 | ...(hostingProvider ? { provider: hostingProvider.kind } : {}), | ||
| 62 | ...(owner ? { owner } : {}), | ||
| 63 | ...(repositoryName ? { name: repositoryName } : {}), | ||
| 64 | }; | ||
| 65 | } | ||
| 66 | |||
| 67 | const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; | ||
| 68 | const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); | ||
| 69 | const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); | ||
| 70 | |||
| 71 | interface RepositoryIdentityResolverOptions { | ||
| 72 | readonly cacheCapacity?: number; | ||
| 73 | readonly positiveCacheTtl?: Duration.Input; | ||
| 74 | readonly negativeCacheTtl?: Duration.Input; | ||
| 75 | } | ||
| 76 | |||
| 77 | async function resolveRepositoryIdentityCacheKey(cwd: string): Promise<string> { | ||
| 78 | let cacheKey = cwd; | ||
| 79 | |||
| 80 | try { | ||
| 81 | const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { | ||
| 82 | allowNonZeroExit: true, | ||
| 83 | }); | ||
| 84 | if (topLevelResult.code !== 0) { | ||
| 85 | return cacheKey; | ||
| 86 | } | ||
| 87 | |||
| 88 | const candidate = topLevelResult.stdout.trim(); | ||
| 89 | if (candidate.length > 0) { | ||
| 90 | cacheKey = candidate; | ||
| 91 | } | ||
| 92 | } catch { | ||
| 93 | return cacheKey; | ||
| 94 | } | ||
| 95 | |||
| 96 | return cacheKey; | ||
| 97 | } | ||
| 98 | |||
| 99 | async function resolveRepositoryIdentityFromCacheKey( | ||
| 100 | cacheKey: string, | ||
| 101 | ): Promise<RepositoryIdentity | null> { | ||
| 102 | try { | ||
| 103 | const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { | ||
| 104 | allowNonZeroExit: true, | ||
| 105 | }); | ||
| 106 | if (remoteResult.code !== 0) { | ||
| 107 | return null; | ||
| 108 | } | ||
| 109 | |||
| 110 | const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); | ||
| 111 | return remote ? buildRepositoryIdentity(remote) : null; | ||
| 112 | } catch { | ||
| 113 | return null; | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( | ||
| 118 | function* (options: RepositoryIdentityResolverOptions = {}) { | ||
| 119 | const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>({ | ||
| 120 | capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, | ||
| 121 | lookup: (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), | ||
| 122 | timeToLive: Exit.match({ | ||
| 123 | onSuccess: (value) => | ||
| 124 | value === null | ||
| 125 | ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) | ||
| 126 | : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), | ||
| 127 | onFailure: () => Duration.zero, | ||
| 128 | }), | ||
| 129 | }); | ||
| 130 | |||
| 131 | const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( | ||
| 132 | "RepositoryIdentityResolver.resolve", | ||
| 133 | )(function* (cwd) { | ||
| 134 | const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); | ||
| 135 | return yield* Cache.get(repositoryIdentityCache, cacheKey); | ||
| 136 | }); | ||
| 137 | |||
| 138 | return { | ||
| 139 | resolve, | ||
| 140 | } satisfies RepositoryIdentityResolverShape; | ||
| 141 | }, | ||
| 142 | ); | ||
| 143 | |||
| 144 | export const RepositoryIdentityResolverLive = Layer.effect( | ||
| 145 | RepositoryIdentityResolver, | ||
| 146 | makeRepositoryIdentityResolver(), | ||
| 147 | ); | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; | ||
| 2 | import { ServiceMap } from "effect"; | ||
| 3 | import type { Effect } from "effect"; | ||
| 4 | |||
| 5 | export interface RepositoryIdentityResolverShape { | ||
| 6 | readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>; | ||
| 7 | } | ||
| 8 | |||
| 9 | export class RepositoryIdentityResolver extends ServiceMap.Service< | ||
| 10 | RepositoryIdentityResolver, | ||
| 11 | RepositoryIdentityResolverShape | ||
| 12 | >()("t3/project/Services/RepositoryIdentityResolver") {} | ||
| @@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 4 unmodified lines | |||
| 5 | 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 { · 72 unmodified lines | |||
| 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 = { · 4 unmodified lines | |||
| 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?: { · 171 unmodified lines | |||
| 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?: { · 140 unmodified lines | |||
| 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) => { · 647 unmodified lines | |||
| 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) => { · 3 unmodified lines | |||
| 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) => { · 911 unmodified lines | |||
| 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) => { · 496 unmodified lines | |||
| 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({ |
| @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" · 43 unmodified lines | |||
| 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( · 144 unmodified lines | |||
| 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), |
| @@ -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( · 5 unmodified lines | |||
| 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( · 8 unmodified lines | |||
| 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")); |
| @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap · 26 unmodified lines | |||
| 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* () { · 229 unmodified lines | |||
| 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* () { · 40 unmodified lines | |||
| 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* () { · 4 unmodified lines | |||
| 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* () { · 28 unmodified lines | |||
| 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 | ||
| @@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; · 46 unmodified lines | |||
| 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( · 14 unmodified lines | |||
| 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( · 40 unmodified lines | |||
| 113 | }); | 117 | }); |
| 114 | }; | 118 | }; |
| 115 | 119 | ||
| 120 | const enrichProjectEvent = ( | ||
| 121 | event: OrchestrationEvent, | ||
| 122 | ): Effect.Effect<OrchestrationEvent, never, never> => { | ||
| 123 | switch (event.type) { | ||
| 124 | case "project.created": | ||
| 125 | return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( | ||
| 126 | Effect.map((repositoryIdentity) => ({ | ||
| 127 | ...event, | ||
| 128 | payload: { | ||
| 129 | ...event.payload, | ||
| 130 | repositoryIdentity, | ||
| 131 | }, | ||
| 132 | })), | ||
| 133 | ); | ||
| 134 | case "project.meta-updated": | ||
| 135 | return Effect.gen(function* () { | ||
| 136 | const workspaceRoot = | ||
| 137 | event.payload.workspaceRoot ?? | ||
| 138 | (yield* orchestrationEngine.getReadModel()).projects.find( | ||
| 139 | (project) => project.id === event.payload.projectId, | ||
| 140 | )?.workspaceRoot ?? | ||
| 141 | null; | ||
| 142 | if (workspaceRoot === null) { | ||
| 143 | return event; | ||
| 144 | } | ||
| 145 | |||
| 146 | const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); | ||
| 147 | return { | ||
| 148 | ...event, | ||
| 149 | payload: { | ||
| 150 | ...event.payload, | ||
| 151 | repositoryIdentity, | ||
| 152 | }, | ||
| 153 | } satisfies OrchestrationEvent; | ||
| 154 | }); | ||
| 155 | default: | ||
| 156 | return Effect.succeed(event); | ||
| 157 | } | ||
| 158 | }; | ||
| 159 | |||
| 160 | const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) => | ||
| 161 | Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); | ||
| 162 | |||
| 116 | 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( · 210 unmodified lines | |||
| 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( · 98 unmodified lines | |||
| 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( · 14 unmodified lines | |||
| 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>; |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | |||
| 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> |
| @@ -23,6 +23,7 @@ · 22 unmodified lines | |||
| 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", |
| @@ -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 = [ · 8 unmodified lines | |||
| 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 { · 1 unmodified line | |||
| 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({ · 7 unmodified lines | |||
| 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({ · 24 unmodified lines | |||
| 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({ · 48 unmodified lines | |||
| 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} |
| @@ -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 { · 11 unmodified lines | |||
| 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 { · 16 unmodified lines | |||
| 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: { · 25 unmodified lines | |||
| 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({ · 9 unmodified lines | |||
| 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({ · 3 unmodified lines | |||
| 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({ · 74 unmodified lines | |||
| 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({ · 51 unmodified lines | |||
| 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({ · 47 unmodified lines | |||
| 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); |
| @@ -22,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering"; · 21 unmodified lines | |||
| 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) { · 224 unmodified lines | |||
| 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 { |
| @@ -4,6 +4,7 @@ import "../index.css"; · 3 unmodified lines | |||
| 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 { · 2 unmodified lines | |||
| 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"; · 1 unmodified line | |||
| 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", () => ({ · 6 unmodified lines | |||
| 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 { · 72 unmodified lines | |||
| 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 { · 179 unmodified lines | |||
| 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 · 76 unmodified lines | |||
| 396 | }; | 422 | }; |
| 397 | } | 423 | } |
| 398 | 424 | ||
| 425 | function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { | ||
| 426 | return { | ||
| 427 | sequence, | ||
| 428 | eventId: EventId.makeUnsafe(`event-thread-session-set-${sequence}`), | ||
| 429 | aggregateKind: "thread", | ||
| 430 | aggregateId: threadId, | ||
| 431 | occurredAt: NOW_ISO, | ||
| 432 | commandId: null, | ||
| 433 | causationEventId: null, | ||
| 434 | correlationId: null, | ||
| 435 | metadata: {}, | ||
| 436 | type: "thread.session-set", | ||
| 437 | payload: { | ||
| 438 | threadId, | ||
| 439 | session: { | ||
| 440 | threadId, | ||
| 441 | status: "running", | ||
| 442 | providerName: "codex", | ||
| 443 | runtimeMode: "full-access", | ||
| 444 | activeTurnId: `turn-${threadId}` as TurnId, | ||
| 445 | lastError: null, | ||
| 446 | updatedAt: NOW_ISO, | ||
| 447 | }, | ||
| 448 | }, | ||
| 449 | }; | ||
| 450 | } | ||
| 451 | |||
| 399 | 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> { · 17 unmodified lines | |||
| 419 | ); | 472 | ); |
| 420 | } | 473 | } |
| 421 | 474 | ||
| 475 | function threadRefFor(threadId: ThreadId) { | ||
| 476 | return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | ||
| 477 | } | ||
| 478 | |||
| 479 | function threadKeyFor(threadId: ThreadId): string { | ||
| 480 | return scopedThreadKey(threadRefFor(threadId)); | ||
| 481 | } | ||
| 482 | |||
| 483 | function composerDraftFor(target: string) { | ||
| 484 | const { draftsByThreadKey } = useComposerDraftStore.getState(); | ||
| 485 | return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)]; | ||
| 486 | } | ||
| 487 | |||
| 488 | function draftIdFromPath(pathname: string) { | ||
| 489 | const segments = pathname.split("/"); | ||
| 490 | const draftId = segments[segments.length - 1]; | ||
| 491 | if (!draftId) { | ||
| 492 | throw new Error(`Expected thread path, received "${pathname}".`); | ||
| 493 | } | ||
| 494 | return DraftId.makeUnsafe(draftId); | ||
| 495 | } | ||
| 496 | |||
| 497 | function draftThreadIdFor(draftId: ReturnType<typeof draftIdFromPath>): ThreadId { | ||
| 498 | const draftSession = useComposerDraftStore.getState().getDraftSession(draftId); | ||
| 499 | if (!draftSession) { | ||
| 500 | throw new Error(`Expected draft session for "${draftId}".`); | ||
| 501 | } | ||
| 502 | return draftSession.threadId; | ||
| 503 | } | ||
| 504 | |||
| 505 | function serverThreadPath(threadId: ThreadId): string { | ||
| 506 | return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`; | ||
| 507 | } | ||
| 508 | |||
| 422 | 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( · 24 unmodified lines | |||
| 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 { · 2 unmodified lines | |||
| 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 { · 55 unmodified lines | |||
| 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?: { · 8 unmodified lines | |||
| 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: { · 449 unmodified lines | |||
| 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)", () => { · 98 unmodified lines | |||
| 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)", () => { · 37 unmodified lines | |||
| 1241 | }, | 1326 | }, |
| 1242 | ); | 1327 | ); |
| 1243 | 1328 | ||
| 1329 | it("re-expands the bootstrap project using its scoped key", async () => { | ||
| 1330 | useUiStateStore.setState({ | ||
| 1331 | projectExpandedById: { | ||
| 1332 | [PROJECT_KEY]: false, | ||
| 1333 | }, | ||
| 1334 | projectOrder: [PROJECT_KEY], | ||
| 1335 | threadLastVisitedAtById: {}, | ||
| 1336 | }); | ||
| 1337 | |||
| 1338 | const mounted = await mountChatView({ | ||
| 1339 | viewport: DEFAULT_VIEWPORT, | ||
| 1340 | snapshot: createSnapshotForTargetUser({ | ||
| 1341 | targetMessageId: "msg-user-bootstrap-project-expand" as MessageId, | ||
| 1342 | targetText: "bootstrap project expand", | ||
| 1343 | }), | ||
| 1344 | }); | ||
| 1345 | |||
| 1346 | try { | ||
| 1347 | await vi.waitFor( | ||
| 1348 | () => { | ||
| 1349 | expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); | ||
| 1350 | }, | ||
| 1351 | { timeout: 8_000, interval: 16 }, | ||
| 1352 | ); | ||
| 1353 | } finally { | ||
| 1354 | await mounted.cleanup(); | ||
| 1355 | } | ||
| 1356 | }); | ||
| 1357 | |||
| 1244 | 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)", () => { · 191 unmodified lines | |||
| 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)", () => { · 3 unmodified lines | |||
| 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)", () => { · 239 unmodified lines | |||
| 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)", () => { · 2 unmodified lines | |||
| 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)", () => { · 57 unmodified lines | |||
| 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)", () => { · 2 unmodified lines | |||
| 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)", () => { · 44 unmodified lines | |||
| 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)", () => { · 2 unmodified lines | |||
| 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)", () => { · 103 unmodified lines | |||
| 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)", () => { · 2 unmodified lines | |||
| 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)", () => { · 19 unmodified lines | |||
| 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)", () => { · 52 unmodified lines | |||
| 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)", () => { · 2 unmodified lines | |||
| 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)", () => { · 22 unmodified lines | |||
| 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)", () => { · 85 unmodified lines | |||
| 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)", () => { · 20 unmodified lines | |||
| 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)", () => { · 5 unmodified lines | |||
| 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)", () => { · 8 unmodified lines | |||
| 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)", () => { · 29 unmodified lines | |||
| 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)", () => { · 4 unmodified lines | |||
| 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)", () => { · 133 unmodified lines | |||
| 2448 | } | 2577 | } |
| 2449 | }); | 2578 | }); |
| 2450 | 2579 | ||
| 2451 | it("keeps the new thread selected after clicking the new-thread button", async () => { | 2580 | it("canonicalizes promoted draft threads to the server thread route", async () => { |
| 2452 | 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)", () => { · 15 unmodified lines | |||
| 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)", () => { · 6 unmodified lines | |||
| 2497 | } | 2639 | } |
| 2498 | }); | 2640 | }); |
| 2499 | 2641 | ||
| 2642 | it("canonicalizes stale promoted draft routes to the server thread route", async () => { | ||
| 2643 | const mounted = await mountChatView({ | ||
| 2644 | viewport: DEFAULT_VIEWPORT, | ||
| 2645 | snapshot: createSnapshotForTargetUser({ | ||
| 2646 | targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, | ||
| 2647 | targetText: "draft hydration race test", | ||
| 2648 | }), | ||
| 2649 | }); | ||
| 2650 | |||
| 2651 | try { | ||
| 2652 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 2653 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 2654 | |||
| 2655 | await newThreadButton.click(); | ||
| 2656 | |||
| 2657 | const newThreadPath = await waitForURL( | ||
| 2658 | mounted.router, | ||
| 2659 | (path) => UUID_ROUTE_RE.test(path), | ||
| 2660 | "Route should have changed to a new draft thread UUID.", | ||
| 2661 | ); | ||
| 2662 | const newDraftId = draftIdFromPath(newThreadPath); | ||
| 2663 | const newThreadId = draftThreadIdFor(newDraftId); | ||
| 2664 | |||
| 2665 | await promoteDraftThreadViaDomainEvent(newThreadId); | ||
| 2666 | |||
| 2667 | await mounted.router.navigate({ | ||
| 2668 | to: "/draft/$draftId", | ||
| 2669 | params: { draftId: newDraftId }, | ||
| 2670 | }); | ||
| 2671 | |||
| 2672 | await waitForURL( | ||
| 2673 | mounted.router, | ||
| 2674 | (path) => path === serverThreadPath(newThreadId), | ||
| 2675 | "Stale promoted draft routes should canonicalize to the server thread path.", | ||
| 2676 | ); | ||
| 2677 | |||
| 2678 | await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); | ||
| 2679 | } finally { | ||
| 2680 | await mounted.cleanup(); | ||
| 2681 | } | ||
| 2682 | }); | ||
| 2683 | |||
| 2500 | 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)", () => { · 28 unmodified lines | |||
| 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)", () => { · 44 unmodified lines | |||
| 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)", () => { · 31 unmodified lines | |||
| 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)", () => { · 33 unmodified lines | |||
| 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)", () => { · 6 unmodified lines | |||
| 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)", () => { · 9 unmodified lines | |||
| 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)", () => { · 100 unmodified lines | |||
| 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)", () => { · 114 unmodified lines | |||
| 2925 | } | 3110 | } |
| 2926 | }); | 3111 | }); |
| 2927 | 3112 | ||
| 2928 | it("does not trigger numeric option shortcuts while the composer is focused", async () => { | 3113 | it("submits pending user input after the final option selection resolves the draft answers", async () => { |
| 2929 | 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)", () => { · 5 unmodified lines | |||
| 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)", () => { · 5 unmodified lines | |||
| 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)", () => { · 1 unmodified line | |||
| 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 | }, |
| @@ -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 { · 3 unmodified lines | |||
| 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", () => { · 151 unmodified lines | |||
| 170 | }); | 174 | }); |
| 171 | }); | 175 | }); |
| 172 | 176 | ||
| 177 | describe("shouldWriteThreadErrorToCurrentServerThread", () => { | ||
| 178 | it("routes errors to the active server thread when route and target match", () => { | ||
| 179 | const threadId = ThreadId.makeUnsafe("thread-1"); | ||
| 180 | const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); | ||
| 181 | |||
| 182 | expect( | ||
| 183 | shouldWriteThreadErrorToCurrentServerThread({ | ||
| 184 | serverThread: { | ||
| 185 | environmentId: localEnvironmentId, | ||
| 186 | id: threadId, | ||
| 187 | }, | ||
| 188 | routeThreadRef, | ||
| 189 | targetThreadId: threadId, | ||
| 190 | }), | ||
| 191 | ).toBe(true); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("does not route draft-thread errors into server-backed state", () => { | ||
| 195 | const threadId = ThreadId.makeUnsafe("thread-1"); | ||
| 196 | |||
| 197 | expect( | ||
| 198 | shouldWriteThreadErrorToCurrentServerThread({ | ||
| 199 | serverThread: undefined, | ||
| 200 | routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), | ||
| 201 | targetThreadId: threadId, | ||
| 202 | }), | ||
| 203 | ).toBe(false); | ||
| 204 | }); | ||
| 205 | }); | ||
| 206 | |||
| 173 | const makeThread = (input?: { | 207 | const makeThread = (input?: { |
| 174 | id?: ThreadId; | 208 | id?: ThreadId; |
| 175 | latestTurn?: { | 209 | latestTurn?: { |
| @@ -181,6 +215,7 @@ const makeThread = (input?: { · 5 unmodified lines | |||
| 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?: { · 21 unmodified lines | |||
| 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>>) · 14 unmodified lines | |||
| 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>>) · 62 unmodified lines | |||
| 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", () => { · 19 unmodified lines | |||
| 326 | }), | 369 | }), |
| 327 | ]); | 370 | ]); |
| 328 | 371 | ||
| 329 | await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); | 372 | await expect( |
| 373 | waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), | ||
| 374 | ).resolves.toBe(true); | ||
| 330 | }); | 375 | }); |
| 331 | 376 | ||
| 332 | 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", () => { · 36 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 23 unmodified lines | |||
| 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", () => { · 30 unmodified lines | |||
| 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", () => { · 39 unmodified lines | |||
| 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", |
| @@ -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( · 14 unmodified lines | |||
| 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( · 14 unmodified lines | |||
| 44 | }; | 52 | }; |
| 45 | } | 53 | } |
| 46 | 54 | ||
| 55 | export function shouldWriteThreadErrorToCurrentServerThread(input: { | ||
| 56 | serverThread: | ||
| 57 | | { | ||
| 58 | environmentId: EnvironmentId; | ||
| 59 | id: ThreadId; | ||
| 60 | } | ||
| 61 | | null | ||
| 62 | | undefined; | ||
| 63 | routeThreadRef: ScopedThreadRef; | ||
| 64 | targetThreadId: ThreadId; | ||
| 65 | }): boolean { | ||
| 66 | return Boolean( | ||
| 67 | input.serverThread && | ||
| 68 | input.targetThreadId === input.routeThreadRef.threadId && | ||
| 69 | input.serverThread.environmentId === input.routeThreadRef.environmentId && | ||
| 70 | input.serverThread.id === input.targetThreadId, | ||
| 71 | ); | ||
| 72 | } | ||
| 73 | |||
| 47 | 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 { · 142 unmodified lines | |||
| 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( · 16 unmodified lines | |||
| 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); |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | |||
| 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 { · 4 unmodified lines | |||
| 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 { · 2 unmodified lines | |||
| 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 · 1 unmodified line | |||
| 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 { · 28 unmodified lines | |||
| 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 { · 42 unmodified lines | |||
| 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 { · 2 unmodified lines | |||
| 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 { · 1 unmodified line | |||
| 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 { · 50 unmodified lines | |||
| 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 · 10 unmodified lines | |||
| 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 · 1 unmodified line | |||
| 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 = ( · 42 unmodified lines | |||
| 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: { · 71 unmodified lines | |||
| 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 { · 5 unmodified lines | |||
| 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({ · 3 unmodified lines | |||
| 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({ · 39 unmodified lines | |||
| 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({ · 14 unmodified lines | |||
| 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({ · 13 unmodified lines | |||
| 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({ · 19 unmodified lines | |||
| 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) { · 6 unmodified lines | |||
| 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) { · 36 unmodified lines | |||
| 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) { · 3 unmodified lines | |||
| 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) { · 71 unmodified lines | |||
| 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) { · 6 unmodified lines | |||
| 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) { · 2 unmodified lines | |||
| 770 | ), | 910 | ), |
| 771 | ); | 911 | ); |
| 772 | }, | 912 | }, |
| 773 | [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], | 913 | [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt], |
| 774 | ); | 914 | ); |
| 775 | 915 | ||
| 776 | const fallbackDraftProject = useStore((state) => | 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) { · 9 unmodified lines | |||
| 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) { · 13 unmodified lines | |||
| 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) { · 1 unmodified line | |||
| 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) { · 19 unmodified lines | |||
| 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) { · 17 unmodified lines | |||
| 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) { · 13 unmodified lines | |||
| 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) { · 392 unmodified lines | |||
| 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) { · 29 unmodified lines | |||
| 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) { · 131 unmodified lines | |||
| 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) { · 14 unmodified lines | |||
| 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) { · 24 unmodified lines | |||
| 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) { · 17 unmodified lines | |||
| 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) { · 16 unmodified lines | |||
| 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) { · 6 unmodified lines | |||
| 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) { · 20 unmodified lines | |||
| 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) { · 40 unmodified lines | |||
| 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) { · 9 unmodified lines | |||
| 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) { · 9 unmodified lines | |||
| 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) { · 97 unmodified lines | |||
| 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) { · 1 unmodified line | |||
| 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) { · 1 unmodified line | |||
| 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) { · 29 unmodified lines | |||
| 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) { · 33 unmodified lines | |||
| 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) { · 323 unmodified lines | |||
| 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) { · 24 unmodified lines | |||
| 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) { · 7 unmodified lines | |||
| 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) { · 54 unmodified lines | |||
| 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) { · 1 unmodified line | |||
| 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) { · 11 unmodified lines | |||
| 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) { · 7 unmodified lines | |||
| 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) { · 3 unmodified lines | |||
| 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) { · 6 unmodified lines | |||
| 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) { · 2 unmodified lines | |||
| 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) { · 188 unmodified lines | |||
| 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) { · 22 unmodified lines | |||
| 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) { · 16 unmodified lines | |||
| 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) { · 10 unmodified lines | |||
| 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) { · 26 unmodified lines | |||
| 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) { · 60 unmodified lines | |||
| 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) { · 130 unmodified lines | |||
| 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) { · 5 unmodified lines | |||
| 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) { · 16 unmodified lines | |||
| 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) { · 16 unmodified lines | |||
| 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) { · 9 unmodified lines | |||
| 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) { · 60 unmodified lines | |||
| 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) { · 48 unmodified lines | |||
| 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) { · 57 unmodified lines | |||
| 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) { · 65 unmodified lines | |||
| 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) { · 25 unmodified lines | |||
| 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) { · 14 unmodified lines | |||
| 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) { · 25 unmodified lines | |||
| 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) { · 2 unmodified lines | |||
| 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) { · 3 unmodified lines | |||
| 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) { · 190 unmodified lines | |||
| 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) { · 8 unmodified lines | |||
| 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) { · 46 unmodified lines | |||
| 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) { · 2 unmodified lines | |||
| 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) { · 39 unmodified lines | |||
| 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) { · 61 unmodified lines | |||
| 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) { · 62 unmodified lines | |||
| 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) { · 336 unmodified lines | |||
| 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) { · 6 unmodified lines | |||
| 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) { · 13 unmodified lines | |||
| 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) { · 10 unmodified lines | |||
| 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 |
| @@ -2,7 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; · 1 unmodified line | |||
| 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"; · 13 unmodified lines | |||
| 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) { · 138 unmodified lines | |||
| 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) { · 66 unmodified lines | |||
| 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) { · 53 unmodified lines | |||
| 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) { · 6 unmodified lines | |||
| 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) { · 3 unmodified lines | |||
| 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" }; |
| @@ -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"; · 1 unmodified line | |||
| 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 { · 3 unmodified lines | |||
| 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 { · 1 unmodified line | |||
| 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 () => { · 16 unmodified lines | |||
| 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", () => ({ · 40 unmodified lines | |||
| 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() { · 18 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 48 unmodified lines | |||
| 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", () => { · 11 unmodified lines | |||
| 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", () => { · 7 unmodified lines | |||
| 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 | } |
| @@ -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 { · 39 unmodified lines | |||
| 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 }) { · 145 unmodified lines | |||
| 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 · 26 unmodified lines | |||
| 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 · 8 unmodified lines | |||
| 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 · 8 unmodified lines | |||
| 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 · 4 unmodified lines | |||
| 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 · 66 unmodified lines | |||
| 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 · 12 unmodified lines | |||
| 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 · 11 unmodified lines | |||
| 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 · 182 unmodified lines | |||
| 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 · 152 unmodified lines | |||
| 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 · 69 unmodified lines | |||
| 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 | > |
| @@ -2,6 +2,7 @@ import "../index.css"; · 1 unmodified line | |||
| 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 } · 10 unmodified lines | |||
| 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", () => ({ · 9 unmodified lines | |||
| 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)?:\/\/.*/); · 9 unmodified lines | |||
| 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 { · 100 unmodified lines | |||
| 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> }> { · 125 unmodified lines | |||
| 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", () => { · 54 unmodified lines | |||
| 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 | ||
| @@ -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 { · 19 unmodified lines | |||
| 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 { · 22 unmodified lines | |||
| 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 { · 3 unmodified lines | |||
| 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({ · 19 unmodified lines | |||
| 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({ · 21 unmodified lines | |||
| 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"> |
| @@ -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"; · 19 unmodified lines | |||
| 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 { · 3 unmodified lines | |||
| 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({ · 33 unmodified lines | |||
| 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({ · 5 unmodified lines | |||
| 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 = |
| @@ -20,7 +20,7 @@ import { · 19 unmodified lines | |||
| 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 { · 1 unmodified line | |||
| 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 { · 591 unmodified lines | |||
| 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 { · 11 unmodified lines | |||
| 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", |
| @@ -12,20 +12,7 @@ import { · 11 unmodified lines | |||
| 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"; · 13 unmodified lines | |||
| 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"; · 2 unmodified lines | |||
| 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 { · 5 unmodified lines | |||
| 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 { · 28 unmodified lines | |||
| 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 { · 6 unmodified lines | |||
| 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 = { · 8 unmodified lines | |||
| 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({ · 14 unmodified lines | |||
| 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( · 82 unmodified lines | |||
| 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) { · 6 unmodified lines | |||
| 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) { · 1 unmodified line | |||
| 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) { · 4 unmodified lines | |||
| 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) { · 3 unmodified lines | |||
| 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) { · 13 unmodified lines | |||
| 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) { · 1 unmodified line | |||
| 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) { · 9 unmodified lines | |||
| 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) { · 5 unmodified lines | |||
| 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) { · 12 unmodified lines | |||
| 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() { · 10 unmodified lines | |||
| 803 | }); | 995 | }); |
| 804 | }); | 996 | }); |
| 805 | }, []); | 997 | }, []); |
| 998 | const sidebarThreads = useStore( | ||
| 999 | useShallow( | ||
| 1000 | useMemo( | ||
| 1001 | () => (state) => | ||
| 1002 | selectSidebarThreadsForProjectRef( | ||
| 1003 | state, | ||
| 1004 | scopeProjectRef(project.environmentId, project.id), | ||
| 1005 | ), | ||
| 1006 | [project.environmentId, project.id], | ||
| 1007 | ), | ||
| 1008 | ), | ||
| 1009 | ); | ||
| 1010 | const sidebarThreadByKey = useMemo( | ||
| 1011 | () => | ||
| 1012 | new Map( | ||
| 1013 | sidebarThreads.map( | ||
| 1014 | (thread) => | ||
| 1015 | [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, | ||
| 1016 | ), | ||
| 1017 | ), | ||
| 1018 | [sidebarThreads], | ||
| 1019 | ); | ||
| 1020 | const projectThreads = useMemo( | ||
| 1021 | () => | ||
| 1022 | sidebarThreads.filter( | ||
| 1023 | (thread) => | ||
| 1024 | scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)) === | ||
| 1025 | project.projectKey, | ||
| 1026 | ), | ||
| 1027 | [project.projectKey, sidebarThreads], | ||
| 1028 | ); | ||
| 1029 | const projectExpanded = useUiStateStore( | ||
| 1030 | (state) => state.projectExpandedById[project.projectKey] ?? true, | ||
| 1031 | ); | ||
| 1032 | const threadLastVisitedAts = useUiStateStore( | ||
| 1033 | useShallow((state) => | ||
| 1034 | projectThreads.map( | ||
| 1035 | (thread) => | ||
| 1036 | state.threadLastVisitedAtById[ | ||
| 1037 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) | ||
| 1038 | ] ?? null, | ||
| 1039 | ), | ||
| 1040 | ), | ||
| 1041 | ); | ||
| 1042 | const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null); | ||
| 1043 | const [renamingTitle, setRenamingTitle] = useState(""); | ||
| 1044 | const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null); | ||
| 1045 | const renamingCommittedRef = useRef(false); | ||
| 1046 | const renamingInputRef = useRef<HTMLInputElement | null>(null); | ||
| 1047 | const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>()); | ||
| 1048 | |||
| 1049 | const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { | ||
| 1050 | const lastVisitedAtByThreadKey = new Map( | ||
| 1051 | projectThreads.map((thread, index) => [ | ||
| 1052 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 1053 | threadLastVisitedAts[index] ?? null, | ||
| 1054 | ]), | ||
| 1055 | ); | ||
| 1056 | const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { | ||
| 1057 | const lastVisitedAt = lastVisitedAtByThreadKey.get( | ||
| 1058 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 1059 | ); | ||
| 1060 | return resolveThreadStatusPill({ | ||
| 1061 | thread: { | ||
| 1062 | ...thread, | ||
| 1063 | ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), | ||
| 1064 | }, | ||
| 1065 | }); | ||
| 1066 | }; | ||
| 1067 | const visibleProjectThreads = sortThreadsForSidebar( | ||
| 1068 | projectThreads.filter((thread) => thread.archivedAt === null), | ||
| 1069 | threadSortOrder, | ||
| 1070 | ); | ||
| 1071 | const projectStatus = resolveProjectStatusIndicator( | ||
| 1072 | visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), | ||
| 1073 | ); | ||
| 1074 | return { | ||
| 1075 | orderedProjectThreadKeys: visibleProjectThreads.map((thread) => | ||
| 1076 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 1077 | ), | ||
| 1078 | projectStatus, | ||
| 1079 | visibleProjectThreads, | ||
| 1080 | }; | ||
| 1081 | }, [projectThreads, threadLastVisitedAts, threadSortOrder]); | ||
| 806 | 1082 | ||
| 807 | const attemptArchiveThread = useCallback( | 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() { · 2 unmodified lines | |||
| 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() { · 2 unmodified lines | |||
| 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() { · 2 unmodified lines | |||
| 1060 | [], | 1525 | [], |
| 1061 | ); | 1526 | ); |
| 1062 | 1527 | ||
| 1063 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ | ||
| 1064 | threadId: ThreadId; | ||
| 1065 | }>({ | ||
| 1066 | onCopy: (ctx) => { | ||
| 1067 | toastManager.add({ | ||
| 1068 | type: "success", | ||
| 1069 | title: "Thread ID copied", | ||
| 1070 | description: ctx.threadId, | ||
| 1071 | }); | ||
| 1072 | }, | ||
| 1073 | onError: (error) => { | ||
| 1074 | toastManager.add({ | ||
| 1075 | type: "error", | ||
| 1076 | title: "Failed to copy thread ID", | ||
| 1077 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1078 | }); | ||
| 1079 | }, | ||
| 1080 | }); | ||
| 1081 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ | ||
| 1082 | path: string; | ||
| 1083 | }>({ | ||
| 1084 | onCopy: (ctx) => { | ||
| 1085 | toastManager.add({ | ||
| 1086 | type: "success", | ||
| 1087 | title: "Path copied", | ||
| 1088 | description: ctx.path, | ||
| 1089 | }); | ||
| 1090 | }, | ||
| 1091 | onError: (error) => { | ||
| 1092 | toastManager.add({ | ||
| 1093 | type: "error", | ||
| 1094 | title: "Failed to copy path", | ||
| 1095 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1096 | }); | ||
| 1097 | }, | ||
| 1098 | }); | ||
| 1099 | 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() { · 6 unmodified lines | |||
| 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() { · 9 unmodified lines | |||
| 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() { · 4 unmodified lines | |||
| 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() { · 17 unmodified lines | |||
| 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() { · 31 unmodified lines | |||
| 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() { · 1 unmodified line | |||
| 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() { · 2 unmodified lines | |||
| 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() { · 7 unmodified lines | |||
| 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() { · 3 unmodified lines | |||
| 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() { · 38 unmodified lines | |||
| 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() { · 56 unmodified lines | |||
| 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 | </> |
| @@ -0,0 +1,247 @@ | |||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 4 | import { ThreadId } from "@t3tools/contracts"; | ||
| 5 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 6 | import { render } from "vitest-browser-react"; | ||
| 7 | |||
| 8 | const { | ||
| 9 | terminalConstructorSpy, | ||
| 10 | terminalDisposeSpy, | ||
| 11 | fitAddonFitSpy, | ||
| 12 | fitAddonLoadSpy, | ||
| 13 | environmentApiById, | ||
| 14 | readEnvironmentApiMock, | ||
| 15 | readLocalApiMock, | ||
| 16 | } = vi.hoisted(() => ({ | ||
| 17 | terminalConstructorSpy: vi.fn(), | ||
| 18 | terminalDisposeSpy: vi.fn(), | ||
| 19 | fitAddonFitSpy: vi.fn(), | ||
| 20 | fitAddonLoadSpy: vi.fn(), | ||
| 21 | environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(), | ||
| 22 | readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), | ||
| 23 | readLocalApiMock: vi.fn< | ||
| 24 | () => | ||
| 25 | | { | ||
| 26 | contextMenu: { show: ReturnType<typeof vi.fn> }; | ||
| 27 | shell: { openExternal: ReturnType<typeof vi.fn> }; | ||
| 28 | } | ||
| 29 | | undefined | ||
| 30 | >(() => ({ | ||
| 31 | contextMenu: { show: vi.fn(async () => null) }, | ||
| 32 | shell: { openExternal: vi.fn(async () => undefined) }, | ||
| 33 | })), | ||
| 34 | })); | ||
| 35 | |||
| 36 | vi.mock("@xterm/addon-fit", () => ({ | ||
| 37 | FitAddon: class MockFitAddon { | ||
| 38 | fit = fitAddonFitSpy; | ||
| 39 | }, | ||
| 40 | })); | ||
| 41 | |||
| 42 | vi.mock("@xterm/xterm", () => ({ | ||
| 43 | Terminal: class MockTerminal { | ||
| 44 | cols = 80; | ||
| 45 | rows = 24; | ||
| 46 | options: { theme?: unknown } = {}; | ||
| 47 | buffer = { | ||
| 48 | active: { | ||
| 49 | viewportY: 0, | ||
| 50 | baseY: 0, | ||
| 51 | getLine: vi.fn(() => null), | ||
| 52 | }, | ||
| 53 | }; | ||
| 54 | |||
| 55 | constructor(options: unknown) { | ||
| 56 | terminalConstructorSpy(options); | ||
| 57 | } | ||
| 58 | |||
| 59 | loadAddon(addon: unknown) { | ||
| 60 | fitAddonLoadSpy(addon); | ||
| 61 | } | ||
| 62 | |||
| 63 | open() {} | ||
| 64 | |||
| 65 | write() {} | ||
| 66 | |||
| 67 | clear() {} | ||
| 68 | |||
| 69 | clearSelection() {} | ||
| 70 | |||
| 71 | focus() {} | ||
| 72 | |||
| 73 | refresh() {} | ||
| 74 | |||
| 75 | scrollToBottom() {} | ||
| 76 | |||
| 77 | hasSelection() { | ||
| 78 | return false; | ||
| 79 | } | ||
| 80 | |||
| 81 | getSelection() { | ||
| 82 | return ""; | ||
| 83 | } | ||
| 84 | |||
| 85 | getSelectionPosition() { | ||
| 86 | return null; | ||
| 87 | } | ||
| 88 | |||
| 89 | attachCustomKeyEventHandler() { | ||
| 90 | return true; | ||
| 91 | } | ||
| 92 | |||
| 93 | registerLinkProvider() { | ||
| 94 | return { dispose: vi.fn() }; | ||
| 95 | } | ||
| 96 | |||
| 97 | onData() { | ||
| 98 | return { dispose: vi.fn() }; | ||
| 99 | } | ||
| 100 | |||
| 101 | onSelectionChange() { | ||
| 102 | return { dispose: vi.fn() }; | ||
| 103 | } | ||
| 104 | |||
| 105 | dispose() { | ||
| 106 | terminalDisposeSpy(); | ||
| 107 | } | ||
| 108 | }, | ||
| 109 | })); | ||
| 110 | |||
| 111 | vi.mock("~/environmentApi", () => ({ | ||
| 112 | readEnvironmentApi: readEnvironmentApiMock, | ||
| 113 | })); | ||
| 114 | |||
| 115 | vi.mock("~/localApi", () => ({ | ||
| 116 | readLocalApi: readLocalApiMock, | ||
| 117 | })); | ||
| 118 | |||
| 119 | import { TerminalViewport } from "./ThreadTerminalDrawer"; | ||
| 120 | |||
| 121 | const THREAD_ID = ThreadId.makeUnsafe("thread-terminal-browser"); | ||
| 122 | |||
| 123 | function createEnvironmentApi() { | ||
| 124 | return { | ||
| 125 | terminal: { | ||
| 126 | open: vi.fn(async () => ({ | ||
| 127 | threadId: THREAD_ID, | ||
| 128 | terminalId: "default", | ||
| 129 | cwd: "/repo/project", | ||
| 130 | worktreePath: null, | ||
| 131 | status: "running" as const, | ||
| 132 | pid: 123, | ||
| 133 | history: "", | ||
| 134 | exitCode: null, | ||
| 135 | exitSignal: null, | ||
| 136 | updatedAt: "2026-04-07T00:00:00.000Z", | ||
| 137 | })), | ||
| 138 | write: vi.fn(async () => undefined), | ||
| 139 | resize: vi.fn(async () => undefined), | ||
| 140 | }, | ||
| 141 | }; | ||
| 142 | } | ||
| 143 | |||
| 144 | async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scopeThreadRef> }) { | ||
| 145 | const host = document.createElement("div"); | ||
| 146 | host.style.width = "800px"; | ||
| 147 | host.style.height = "400px"; | ||
| 148 | document.body.append(host); | ||
| 149 | |||
| 150 | const screen = await render( | ||
| 151 | <TerminalViewport | ||
| 152 | threadRef={props.threadRef} | ||
| 153 | threadId={THREAD_ID} | ||
| 154 | terminalId="default" | ||
| 155 | terminalLabel="Terminal" | ||
| 156 | cwd="/repo/project" | ||
| 157 | onSessionExited={() => undefined} | ||
| 158 | onAddTerminalContext={() => undefined} | ||
| 159 | focusRequestId={0} | ||
| 160 | autoFocus={false} | ||
| 161 | resizeEpoch={0} | ||
| 162 | drawerHeight={320} | ||
| 163 | />, | ||
| 164 | { container: host }, | ||
| 165 | ); | ||
| 166 | |||
| 167 | return { | ||
| 168 | rerender: async (nextProps: { threadRef: ReturnType<typeof scopeThreadRef> }) => { | ||
| 169 | await screen.rerender( | ||
| 170 | <TerminalViewport | ||
| 171 | threadRef={nextProps.threadRef} | ||
| 172 | threadId={THREAD_ID} | ||
| 173 | terminalId="default" | ||
| 174 | terminalLabel="Terminal" | ||
| 175 | cwd="/repo/project" | ||
| 176 | onSessionExited={() => undefined} | ||
| 177 | onAddTerminalContext={() => undefined} | ||
| 178 | focusRequestId={0} | ||
| 179 | autoFocus={false} | ||
| 180 | resizeEpoch={0} | ||
| 181 | drawerHeight={320} | ||
| 182 | />, | ||
| 183 | ); | ||
| 184 | }, | ||
| 185 | cleanup: async () => { | ||
| 186 | await screen.unmount(); | ||
| 187 | host.remove(); | ||
| 188 | }, | ||
| 189 | }; | ||
| 190 | } | ||
| 191 | |||
| 192 | describe("TerminalViewport", () => { | ||
| 193 | afterEach(() => { | ||
| 194 | environmentApiById.clear(); | ||
| 195 | readEnvironmentApiMock.mockClear(); | ||
| 196 | readLocalApiMock.mockClear(); | ||
| 197 | terminalConstructorSpy.mockClear(); | ||
| 198 | terminalDisposeSpy.mockClear(); | ||
| 199 | fitAddonFitSpy.mockClear(); | ||
| 200 | fitAddonLoadSpy.mockClear(); | ||
| 201 | }); | ||
| 202 | |||
| 203 | it("does not create a terminal when APIs are unavailable", async () => { | ||
| 204 | readEnvironmentApiMock.mockReturnValueOnce(undefined); | ||
| 205 | readLocalApiMock.mockReturnValueOnce(undefined); | ||
| 206 | |||
| 207 | const mounted = await mountTerminalViewport({ | ||
| 208 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 209 | }); | ||
| 210 | |||
| 211 | try { | ||
| 212 | await vi.waitFor(() => { | ||
| 213 | expect(terminalConstructorSpy).not.toHaveBeenCalled(); | ||
| 214 | }); | ||
| 215 | } finally { | ||
| 216 | await mounted.cleanup(); | ||
| 217 | } | ||
| 218 | }); | ||
| 219 | |||
| 220 | it("reopens the terminal when the scoped thread reference changes", async () => { | ||
| 221 | const environmentA = createEnvironmentApi(); | ||
| 222 | const environmentB = createEnvironmentApi(); | ||
| 223 | environmentApiById.set("environment-a", environmentA); | ||
| 224 | environmentApiById.set("environment-b", environmentB); | ||
| 225 | |||
| 226 | const mounted = await mountTerminalViewport({ | ||
| 227 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 228 | }); | ||
| 229 | |||
| 230 | try { | ||
| 231 | await vi.waitFor(() => { | ||
| 232 | expect(environmentA.terminal.open).toHaveBeenCalledTimes(1); | ||
| 233 | }); | ||
| 234 | |||
| 235 | await mounted.rerender({ | ||
| 236 | threadRef: scopeThreadRef("environment-b" as never, THREAD_ID), | ||
| 237 | }); | ||
| 238 | |||
| 239 | await vi.waitFor(() => { | ||
| 240 | expect(environmentB.terminal.open).toHaveBeenCalledTimes(1); | ||
| 241 | }); | ||
| 242 | expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); | ||
| 243 | } finally { | ||
| 244 | await mounted.cleanup(); | ||
| 245 | } | ||
| 246 | }); | ||
| 247 | }); | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | 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 { · 24 unmodified lines | |||
| 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( · 170 unmodified lines | |||
| 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 { · 8 unmodified lines | |||
| 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({ · 31 unmodified lines | |||
| 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({ · 11 unmodified lines | |||
| 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({ · 54 unmodified lines | |||
| 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({ · 69 unmodified lines | |||
| 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({ · 3 unmodified lines | |||
| 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({ · 140 unmodified lines | |||
| 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({ · 23 unmodified lines | |||
| 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({ · 62 unmodified lines | |||
| 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({ · 8 unmodified lines | |||
| 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({ · 15 unmodified lines | |||
| 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 · 46 unmodified lines | |||
| 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({ · 319 unmodified lines | |||
| 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({ · 15 unmodified lines | |||
| 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"} |
| @@ -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"; · 5 unmodified lines | |||
| 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 { · 18 unmodified lines | |||
| 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({ · 56 unmodified lines | |||
| 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={ |
| @@ -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"; · 4 unmodified lines | |||
| 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 · 77 unmodified lines | |||
| 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", () => { · 22 unmodified lines | |||
| 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 | }); |
| @@ -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( · 54 unmodified lines | |||
| 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( · 4 unmodified lines | |||
| 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( · 20 unmodified lines | |||
| 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; |
| @@ -42,6 +42,8 @@ beforeAll(() => { · 41 unmodified lines | |||
| 42 | }); | 42 | }); |
| 43 | }); | 43 | }); |
| 44 | 44 | ||
| 45 | const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; | ||
| 46 | |||
| 45 | 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", () => { · 37 unmodified lines | |||
| 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", () => { · 39 unmodified lines | |||
| 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" |
| @@ -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 { · 77 unmodified lines | |||
| 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({ · 29 unmodified lines | |||
| 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({ · 408 unmodified lines | |||
| 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 | /> |
| @@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { · 18 unmodified lines | |||
| 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 { · 6 unmodified lines | |||
| 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 { · 10 unmodified lines | |||
| 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( · 18 unmodified lines | |||
| 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: { · 64 unmodified lines | |||
| 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>( · 331 unmodified lines | |||
| 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: { · 62 unmodified lines | |||
| 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: { · 19 unmodified lines | |||
| 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(); |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | |||
| 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({ · 68 unmodified lines | |||
| 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({ · 10 unmodified lines | |||
| 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; |
| @@ -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 { · 19 unmodified lines | |||
| 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({ · 43 unmodified lines | |||
| 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; |
| @@ -6,10 +6,11 @@ import { · 5 unmodified lines | |||
| 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"; · 11 unmodified lines | |||
| 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: { · 86 unmodified lines | |||
| 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: { · 5 unmodified lines | |||
| 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: { · 2 unmodified lines | |||
| 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?: { · 17 unmodified lines | |||
| 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?: { · 16 unmodified lines | |||
| 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)", () => { · 29 unmodified lines | |||
| 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)", () => { · 130 unmodified lines | |||
| 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 · 13 unmodified lines | |||
| 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 · 2 unmodified lines | |||
| 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)", () => { · 18 unmodified lines | |||
| 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 | }); |
| @@ -3,8 +3,8 @@ import { · 2 unmodified lines | |||
| 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 { · 17 unmodified lines | |||
| 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({ · 121 unmodified lines | |||
| 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 | ); |
| @@ -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", () => { · 410 unmodified lines | |||
| 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 | }); | ||
| @@ -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 = { · 21 unmodified lines | |||
| 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 = { · 1 unmodified line | |||
| 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 = { · 2 unmodified lines | |||
| 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> = { · 38 unmodified lines | |||
| 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 · 3 unmodified lines | |||
| 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: { · 1 unmodified line | |||
| 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: { · 4 unmodified lines | |||
| 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: { · 1 unmodified line | |||
| 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, |
| @@ -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 { · 14 unmodified lines | |||
| 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", () => { · 22 unmodified lines | |||
| 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 | ||
| @@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query"; · 12 unmodified lines | |||
| 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 { · 21 unmodified lines | |||
| 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) { · 443 unmodified lines | |||
| 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() { · 50 unmodified lines | |||
| 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() { · 53 unmodified lines | |||
| 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() { · 858 unmodified lines | |||
| 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() { · 6 unmodified lines | |||
| 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() { · 5 unmodified lines | |||
| 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() { · 5 unmodified lines | |||
| 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() { · 26 unmodified lines | |||
| 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() { · 10 unmodified lines | |||
| 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" /> |
| @@ -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"; · 3 unmodified lines | |||
| 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: { · 52 unmodified lines | |||
| 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 · 15 unmodified lines | |||
| 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", () => { · 26 unmodified lines | |||
| 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", () => { · 16 unmodified lines | |||
| 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", () => { · 8 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 13 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 8 unmodified lines | |||
| 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", () => { · 9 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 3 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 5 unmodified lines | |||
| 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", () => { · 3 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 3 unmodified lines | |||
| 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", () => { · 26 unmodified lines | |||
| 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", () => { · 26 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 5 unmodified lines | |||
| 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", () => { · 3 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 3 unmodified lines | |||
| 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", () => { · 7 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 3 unmodified lines | |||
| 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", () => { · 7 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 9 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 8 unmodified lines | |||
| 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", () => { · 8 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 15 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 6 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 35 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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 | ||
| @@ -3,6 +3,7 @@ import { · 2 unmodified lines | |||
| 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 { · 1 unmodified line | |||
| 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"; · 14 unmodified lines | |||
| 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( · 2 unmodified lines | |||
| 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; · 71 unmodified lines | |||
| 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 & · 1 unmodified line | |||
| 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({ · 6 unmodified lines | |||
| 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 { · 6 unmodified lines | |||
| 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 { · 2 unmodified lines | |||
| 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( · 27 unmodified lines | |||
| 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( · 446 unmodified lines | |||
| 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( · 11 unmodified lines | |||
| 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( · 84 unmodified lines | |||
| 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( · 3 unmodified lines | |||
| 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( · 3 unmodified lines | |||
| 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( · 16 unmodified lines | |||
| 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( · 2 unmodified lines | |||
| 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( · 33 unmodified lines | |||
| 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( · 6 unmodified lines | |||
| 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( · 36 unmodified lines | |||
| 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[] { · 4 unmodified lines | |||
| 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[] { · 2 unmodified lines | |||
| 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( · 8 unmodified lines | |||
| 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( · 9 unmodified lines | |||
| 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( · 75 unmodified lines | |||
| 1258 | }; | 1722 | }; |
| 1259 | } | 1723 | } |
| 1260 | 1724 | ||
| 1261 | export const useComposerDraftStore = create<ComposerDraftStoreState>()( | 1725 | function toHydratedDraftThreadState( |
| 1726 | persistedDraftThread: PersistedDraftThreadState, | ||
| 1727 | ): DraftThreadState { | ||
| 1728 | return { | ||
| 1729 | threadId: persistedDraftThread.threadId, | ||
| 1730 | environmentId: persistedDraftThread.environmentId as EnvironmentId, | ||
| 1731 | projectId: persistedDraftThread.projectId, | ||
| 1732 | logicalProjectKey: | ||
| 1733 | persistedDraftThread.logicalProjectKey ?? | ||
| 1734 | projectDraftKey( | ||
| 1735 | scopeProjectRef( | ||
| 1736 | persistedDraftThread.environmentId as EnvironmentId, | ||
| 1737 | persistedDraftThread.projectId, | ||
| 1738 | ), | ||
| 1739 | ), | ||
| 1740 | createdAt: persistedDraftThread.createdAt, | ||
| 1741 | runtimeMode: persistedDraftThread.runtimeMode, | ||
| 1742 | interactionMode: persistedDraftThread.interactionMode, | ||
| 1743 | branch: persistedDraftThread.branch, | ||
| 1744 | worktreePath: persistedDraftThread.worktreePath, | ||
| 1745 | envMode: persistedDraftThread.envMode, | ||
| 1746 | promotedTo: persistedDraftThread.promotedTo | ||
| 1747 | ? scopeThreadRef( | ||
| 1748 | persistedDraftThread.promotedTo.environmentId as EnvironmentId, | ||
| 1749 | persistedDraftThread.promotedTo.threadId as ThreadId, | ||
| 1750 | ) | ||
| 1751 | : null, | ||
| 1752 | }; | ||
| 1753 | } | ||
| 1754 | |||
| 1755 | const composerDraftStore = create<ComposerDraftStoreState>()( | ||
| 1262 | 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>()( · 3 unmodified lines | |||
| 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>()( · 2 unmodified lines | |||
| 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: { · 17 unmodified lines | |||
| 2195 | } | 2771 | } |
| 2196 | 2772 | ||
| 2197 | /** | 2773 | /** |
| 2198 | * Clear a draft thread once the server has materialized the same thread id. | 2774 | * Mark a draft thread as promoting once the server has materialized the same thread id. |
| 2199 | * | 2775 | * |
| 2200 | * 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 | } |
| @@ -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( · 21 unmodified lines | |||
| 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."); |
| @@ -0,0 +1,67 @@ | |||
| 1 | import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { readWsRpcClientEntryForEnvironment, WsRpcClient } from "./wsRpcClient"; | ||
| 4 | |||
| 5 | export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | ||
| 6 | return { | ||
| 7 | terminal: { | ||
| 8 | open: (input) => rpcClient.terminal.open(input as never), | ||
| 9 | write: (input) => rpcClient.terminal.write(input as never), | ||
| 10 | resize: (input) => rpcClient.terminal.resize(input as never), | ||
| 11 | clear: (input) => rpcClient.terminal.clear(input as never), | ||
| 12 | restart: (input) => rpcClient.terminal.restart(input as never), | ||
| 13 | close: (input) => rpcClient.terminal.close(input as never), | ||
| 14 | onEvent: (callback) => rpcClient.terminal.onEvent(callback), | ||
| 15 | }, | ||
| 16 | projects: { | ||
| 17 | searchEntries: rpcClient.projects.searchEntries, | ||
| 18 | writeFile: rpcClient.projects.writeFile, | ||
| 19 | }, | ||
| 20 | git: { | ||
| 21 | pull: rpcClient.git.pull, | ||
| 22 | refreshStatus: rpcClient.git.refreshStatus, | ||
| 23 | onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), | ||
| 24 | listBranches: rpcClient.git.listBranches, | ||
| 25 | createWorktree: rpcClient.git.createWorktree, | ||
| 26 | removeWorktree: rpcClient.git.removeWorktree, | ||
| 27 | createBranch: rpcClient.git.createBranch, | ||
| 28 | checkout: rpcClient.git.checkout, | ||
| 29 | init: rpcClient.git.init, | ||
| 30 | resolvePullRequest: rpcClient.git.resolvePullRequest, | ||
| 31 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, | ||
| 32 | }, | ||
| 33 | orchestration: { | ||
| 34 | getSnapshot: rpcClient.orchestration.getSnapshot, | ||
| 35 | dispatchCommand: rpcClient.orchestration.dispatchCommand, | ||
| 36 | getTurnDiff: rpcClient.orchestration.getTurnDiff, | ||
| 37 | getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, | ||
| 38 | replayEvents: (fromSequenceExclusive) => | ||
| 39 | rpcClient.orchestration | ||
| 40 | .replayEvents({ fromSequenceExclusive }) | ||
| 41 | .then((events) => [...events]), | ||
| 42 | onDomainEvent: (callback, options) => | ||
| 43 | rpcClient.orchestration.onDomainEvent(callback, options), | ||
| 44 | }, | ||
| 45 | }; | ||
| 46 | } | ||
| 47 | |||
| 48 | export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined { | ||
| 49 | if (typeof window === "undefined") { | ||
| 50 | return undefined; | ||
| 51 | } | ||
| 52 | |||
| 53 | if (!environmentId) { | ||
| 54 | return undefined; | ||
| 55 | } | ||
| 56 | |||
| 57 | const entry = readWsRpcClientEntryForEnvironment(environmentId); | ||
| 58 | return entry ? createEnvironmentApi(entry.client) : undefined; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { | ||
| 62 | const api = readEnvironmentApi(environmentId); | ||
| 63 | if (!api) { | ||
| 64 | throw new Error(`Environment API not found for environment ${environmentId}`); | ||
| 65 | } | ||
| 66 | return api; | ||
| 67 | } | ||
| @@ -0,0 +1,65 @@ | |||
| 1 | import { | ||
| 2 | createKnownEnvironmentFromWsUrl, | ||
| 3 | getKnownEnvironmentBaseUrl, | ||
| 4 | type KnownEnvironment, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | function createKnownEnvironmentFromDesktopBootstrap( | ||
| 9 | bootstrap: DesktopEnvironmentBootstrap | null | undefined, | ||
| 10 | ): KnownEnvironment | null { | ||
| 11 | if (!bootstrap?.wsUrl) { | ||
| 12 | return null; | ||
| 13 | } | ||
| 14 | |||
| 15 | return createKnownEnvironmentFromWsUrl({ | ||
| 16 | id: `desktop:${bootstrap.label}`, | ||
| 17 | label: bootstrap.label, | ||
| 18 | source: "desktop-managed", | ||
| 19 | wsUrl: bootstrap.wsUrl, | ||
| 20 | }); | ||
| 21 | } | ||
| 22 | |||
| 23 | export function getPrimaryKnownEnvironment(): KnownEnvironment | null { | ||
| 24 | const desktopEnvironment = createKnownEnvironmentFromDesktopBootstrap( | ||
| 25 | window.desktopBridge?.getLocalEnvironmentBootstrap(), | ||
| 26 | ); | ||
| 27 | if (desktopEnvironment) { | ||
| 28 | return desktopEnvironment; | ||
| 29 | } | ||
| 30 | |||
| 31 | const legacyDesktopWsUrl = window.desktopBridge?.getWsUrl(); | ||
| 32 | if (typeof legacyDesktopWsUrl === "string" && legacyDesktopWsUrl.length > 0) { | ||
| 33 | return createKnownEnvironmentFromWsUrl({ | ||
| 34 | id: "desktop-legacy", | ||
| 35 | label: "Local environment", | ||
| 36 | source: "desktop-managed", | ||
| 37 | wsUrl: legacyDesktopWsUrl, | ||
| 38 | }); | ||
| 39 | } | ||
| 40 | |||
| 41 | const configuredWsUrl = import.meta.env.VITE_WS_URL; | ||
| 42 | if (typeof configuredWsUrl === "string" && configuredWsUrl.length > 0) { | ||
| 43 | return createKnownEnvironmentFromWsUrl({ | ||
| 44 | id: "configured-primary", | ||
| 45 | label: "Primary environment", | ||
| 46 | source: "configured", | ||
| 47 | wsUrl: configuredWsUrl, | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | |||
| 51 | return createKnownEnvironmentFromWsUrl({ | ||
| 52 | id: "window-origin", | ||
| 53 | label: "Primary environment", | ||
| 54 | source: "window-origin", | ||
| 55 | wsUrl: window.location.origin, | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | |||
| 59 | export function resolvePrimaryEnvironmentBootstrapUrl(): string { | ||
| 60 | const baseUrl = getKnownEnvironmentBaseUrl(getPrimaryKnownEnvironment()); | ||
| 61 | if (!baseUrl) { | ||
| 62 | throw new Error("Unable to resolve a known environment bootstrap URL."); | ||
| 63 | } | ||
| 64 | return baseUrl; | ||
| 65 | } | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; | 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 { · 1 unmodified line | |||
| 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() { · 1 unmodified line | |||
| 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 | } |
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | |||
| 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>): { · 34 unmodified lines | |||
| 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() { · 35 unmodified lines | |||
| 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 { · 121 unmodified lines | |||
| 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 | ||
| @@ -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() { · 1 unmodified line | |||
| 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() { · 7 unmodified lines | |||
| 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() { · 2 unmodified lines | |||
| 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() { · 9 unmodified lines | |||
| 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() { · 4 unmodified lines | |||
| 195 | } | 259 | } |
| 196 | } | 260 | } |
| 197 | 261 | ||
| 198 | await deleteThread(threadId); | 262 | await deleteThread(target); |
| 199 | }, | 263 | }, |
| 200 | [appSettings.confirmThreadDelete, deleteThread], | 264 | [confirmThreadDelete, deleteThread, resolveThreadTarget], |
| 201 | ); | 265 | ); |
| 202 | 266 | ||
| 203 | return { | 267 | return { |
| @@ -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> = { · 16 unmodified lines | |||
| 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", () => { · 3 unmodified lines | |||
| 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", () => { · 3 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 2 unmodified lines | |||
| 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, |
| @@ -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 { · 4 unmodified lines | |||
| 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; · 1 unmodified line | |||
| 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: { · 1 unmodified line | |||
| 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: { · 11 unmodified lines | |||
| 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: { · 9 unmodified lines | |||
| 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: { · 6 unmodified lines | |||
| 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: { · 2 unmodified lines | |||
| 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 | } |
| @@ -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 · 11 unmodified lines | |||
| 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(() => { · 33 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 8 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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 | }); |
| @@ -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 { · 11 unmodified lines | |||
| 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( · 11 unmodified lines | |||
| 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 { · 3 unmodified lines | |||
| 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 { · 4 unmodified lines | |||
| 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 { · 2 unmodified lines | |||
| 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 |
| @@ -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 = { · 4 unmodified lines | |||
| 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: { · 2 unmodified lines | |||
| 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 | }); |
| @@ -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(() => { · 3 unmodified lines | |||
| 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", () => { · 20 unmodified lines | |||
| 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", () => { · 17 unmodified lines | |||
| 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", () => { · 16 unmodified lines | |||
| 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", () => { · 11 unmodified lines | |||
| 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", () => { · 18 unmodified lines | |||
| 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, |
| @@ -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 = { · 7 unmodified lines | |||
| 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) { · 68 unmodified lines | |||
| 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) { · 3 unmodified lines | |||
| 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)) { |
| @@ -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 | }); |
| @@ -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 | } |
| @@ -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", () => { · 6 unmodified lines | |||
| 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 | }); | ||
| @@ -4,6 +4,8 @@ import { type CxOptions, cx } from "class-variance-authority"; · 3 unmodified lines | |||
| 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()); · 24 unmodified lines | |||
| 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?: { · 1 unmodified line | |||
| 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) { |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | |||
| 2 | CommandId, | 2 | CommandId, |
| 3 | DEFAULT_SERVER_SETTINGS, | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | type DesktopBridge, | 4 | type DesktopBridge, |
| 5 | EnvironmentId, | ||
| 5 | EventId, | 6 | EventId, |
| 6 | type GitStatusResult, | 7 | type GitStatusResult, |
| 7 | ProjectId, | 8 | ProjectId, |
| @@ -94,6 +95,17 @@ const rpcClientMock = { · 86 unmodified lines | |||
| 94 | vi.mock("./wsRpcClient", () => { | 95 | vi.mock("./wsRpcClient", () => { |
| 95 | return { | 96 | return { |
| 96 | getWsRpcClient: () => rpcClientMock, | 97 | getWsRpcClient: () => rpcClientMock, |
| 98 | getPrimaryWsRpcClientEntry: () => ({ | ||
| 99 | key: "primary", | ||
| 100 | knownEnvironment: { | ||
| 101 | id: "primary", | ||
| 102 | label: "Primary", | ||
| 103 | source: "manual", | ||
| 104 | target: { type: "ws", wsUrl: "ws://localhost:3000" }, | ||
| 105 | }, | ||
| 106 | client: rpcClientMock, | ||
| 107 | environmentId: null, | ||
| 108 | }), | ||
| 97 | __resetWsRpcClientForTests: vi.fn(), | 109 | __resetWsRpcClientForTests: vi.fn(), |
| 98 | }; | 110 | }; |
| 99 | }); | 111 | }); |
| @@ -121,6 +133,7 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn · 21 unmodified lines | |||
| 121 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { | 133 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { |
| 122 | return { | 134 | return { |
| 123 | getWsUrl: () => null, | 135 | getWsUrl: () => null, |
| 136 | getLocalEnvironmentBootstrap: () => null, | ||
| 124 | pickFolder: async () => null, | 137 | pickFolder: async () => null, |
| 125 | confirm: async () => true, | 138 | confirm: async () => true, |
| 126 | setTheme: async () => undefined, | 139 | setTheme: async () => undefined, |
| @@ -157,7 +170,21 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ · 30 unmodified lines | |||
| 157 | }, | 170 | }, |
| 158 | ]; | 171 | ]; |
| 159 | 172 | ||
| 173 | const baseEnvironment = { | ||
| 174 | environmentId: EnvironmentId.makeUnsafe("environment-local"), | ||
| 175 | label: "Local environment", | ||
| 176 | platform: { | ||
| 177 | os: "darwin" as const, | ||
| 178 | arch: "arm64" as const, | ||
| 179 | }, | ||
| 180 | serverVersion: "0.0.0-test", | ||
| 181 | capabilities: { | ||
| 182 | repositoryIdentity: true, | ||
| 183 | }, | ||
| 184 | }; | ||
| 185 | |||
| 160 | const baseServerConfig: ServerConfig = { | 186 | const baseServerConfig: ServerConfig = { |
| 187 | environment: baseEnvironment, | ||
| 161 | cwd: "/tmp/workspace", | 188 | cwd: "/tmp/workspace", |
| 162 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | 189 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", |
| 163 | keybindings: [], | 190 | keybindings: [], |
| @@ -200,12 +227,12 @@ afterEach(() => { · 36 unmodified lines | |||
| 200 | vi.restoreAllMocks(); | 227 | vi.restoreAllMocks(); |
| 201 | }); | 228 | }); |
| 202 | 229 | ||
| 203 | describe("wsNativeApi", () => { | 230 | describe("wsApi", () => { |
| 204 | it("forwards server config fetches directly to the RPC client", async () => { | 231 | it("forwards server config fetches directly to the RPC client", async () => { |
| 205 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); | 232 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); |
| 206 | const { createWsNativeApi } = await import("./wsNativeApi"); | 233 | const { createLocalApi } = await import("./localApi"); |
| 207 | 234 | ||
| 208 | const api = createWsNativeApi(); | 235 | const api = createLocalApi(); |
| 209 | 236 | ||
| 210 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); | 237 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); |
| 211 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); | 238 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); |
| @@ -214,9 +241,9 @@ describe("wsNativeApi", () => { · 2 unmodified lines | |||
| 214 | }); | 241 | }); |
| 215 | 242 | ||
| 216 | it("forwards terminal and orchestration stream events", async () => { | 243 | it("forwards terminal and orchestration stream events", async () => { |
| 217 | const { createWsNativeApi } = await import("./wsNativeApi"); | 244 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 218 | 245 | ||
| 219 | const api = createWsNativeApi(); | 246 | const api = createEnvironmentApi(rpcClientMock as never); |
| 220 | const onTerminalEvent = vi.fn(); | 247 | const onTerminalEvent = vi.fn(); |
| 221 | const onDomainEvent = vi.fn(); | 248 | const onDomainEvent = vi.fn(); |
| 222 | 249 | ||
| @@ -263,9 +290,9 @@ describe("wsNativeApi", () => { · 40 unmodified lines | |||
| 263 | }); | 290 | }); |
| 264 | 291 | ||
| 265 | it("forwards git status stream events", async () => { | 292 | it("forwards git status stream events", async () => { |
| 266 | const { createWsNativeApi } = await import("./wsNativeApi"); | 293 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 267 | 294 | ||
| 268 | const api = createWsNativeApi(); | 295 | const api = createEnvironmentApi(rpcClientMock as never); |
| 269 | const onStatus = vi.fn(); | 296 | const onStatus = vi.fn(); |
| 270 | 297 | ||
| 271 | api.git.onStatus({ cwd: "/repo" }, onStatus); | 298 | api.git.onStatus({ cwd: "/repo" }, onStatus); |
| @@ -279,9 +306,9 @@ describe("wsNativeApi", () => { · 7 unmodified lines | |||
| 279 | 306 | ||
| 280 | it("forwards git status refreshes directly to the RPC client", async () => { | 307 | it("forwards git status refreshes directly to the RPC client", async () => { |
| 281 | rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); | 308 | rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); |
| 282 | const { createWsNativeApi } = await import("./wsNativeApi"); | 309 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 283 | 310 | ||
| 284 | const api = createWsNativeApi(); | 311 | const api = createEnvironmentApi(rpcClientMock as never); |
| 285 | 312 | ||
| 286 | await api.git.refreshStatus({ cwd: "/repo" }); | 313 | await api.git.refreshStatus({ cwd: "/repo" }); |
| 287 | 314 | ||
| @@ -289,9 +316,9 @@ describe("wsNativeApi", () => { · 1 unmodified line | |||
| 289 | }); | 316 | }); |
| 290 | 317 | ||
| 291 | it("forwards orchestration stream subscription options to the RPC client", async () => { | 318 | it("forwards orchestration stream subscription options to the RPC client", async () => { |
| 292 | const { createWsNativeApi } = await import("./wsNativeApi"); | 319 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 293 | 320 | ||
| 294 | const api = createWsNativeApi(); | 321 | const api = createEnvironmentApi(rpcClientMock as never); |
| 295 | const onDomainEvent = vi.fn(); | 322 | const onDomainEvent = vi.fn(); |
| 296 | const onResubscribe = vi.fn(); | 323 | const onResubscribe = vi.fn(); |
| 297 | 324 | ||
| @@ -304,9 +331,9 @@ describe("wsNativeApi", () => { · 6 unmodified lines | |||
| 304 | 331 | ||
| 305 | it("sends orchestration dispatch commands as the direct RPC payload", async () => { | 332 | it("sends orchestration dispatch commands as the direct RPC payload", async () => { |
| 306 | rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); | 333 | rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); |
| 307 | const { createWsNativeApi } = await import("./wsNativeApi"); | 334 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 308 | 335 | ||
| 309 | const api = createWsNativeApi(); | 336 | const api = createEnvironmentApi(rpcClientMock as never); |
| 310 | const command = { | 337 | const command = { |
| 311 | type: "project.create", | 338 | type: "project.create", |
| 312 | commandId: CommandId.makeUnsafe("cmd-1"), | 339 | commandId: CommandId.makeUnsafe("cmd-1"), |
| @@ -326,9 +353,9 @@ describe("wsNativeApi", () => { · 13 unmodified lines | |||
| 326 | 353 | ||
| 327 | it("forwards workspace file writes to the project RPC", async () => { | 354 | it("forwards workspace file writes to the project RPC", async () => { |
| 328 | rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); | 355 | rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); |
| 329 | const { createWsNativeApi } = await import("./wsNativeApi"); | 356 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 330 | 357 | ||
| 331 | const api = createWsNativeApi(); | 358 | const api = createEnvironmentApi(rpcClientMock as never); |
| 332 | await api.projects.writeFile({ | 359 | await api.projects.writeFile({ |
| 333 | cwd: "/tmp/project", | 360 | cwd: "/tmp/project", |
| 334 | relativePath: "plan.md", | 361 | relativePath: "plan.md", |
| @@ -344,9 +371,9 @@ describe("wsNativeApi", () => { · 9 unmodified lines | |||
| 344 | 371 | ||
| 345 | it("forwards full-thread diff requests to the orchestration RPC", async () => { | 372 | it("forwards full-thread diff requests to the orchestration RPC", async () => { |
| 346 | rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); | 373 | rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); |
| 347 | const { createWsNativeApi } = await import("./wsNativeApi"); | 374 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 348 | 375 | ||
| 349 | const api = createWsNativeApi(); | 376 | const api = createEnvironmentApi(rpcClientMock as never); |
| 350 | await api.orchestration.getFullThreadDiff({ | 377 | await api.orchestration.getFullThreadDiff({ |
| 351 | threadId: ThreadId.makeUnsafe("thread-1"), | 378 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 352 | toTurnCount: 1, | 379 | toTurnCount: 1, |
| @@ -366,9 +393,9 @@ describe("wsNativeApi", () => { · 13 unmodified lines | |||
| 366 | }, | 393 | }, |
| 367 | ]; | 394 | ]; |
| 368 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); | 395 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); |
| 369 | const { createWsNativeApi } = await import("./wsNativeApi"); | 396 | const { createLocalApi } = await import("./localApi"); |
| 370 | 397 | ||
| 371 | const api = createWsNativeApi(); | 398 | const api = createLocalApi(); |
| 372 | 399 | ||
| 373 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); | 400 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); |
| 374 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); | 401 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); |
| @@ -380,9 +407,9 @@ describe("wsNativeApi", () => { · 5 unmodified lines | |||
| 380 | enableAssistantStreaming: true, | 407 | enableAssistantStreaming: true, |
| 381 | }; | 408 | }; |
| 382 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); | 409 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); |
| 383 | const { createWsNativeApi } = await import("./wsNativeApi"); | 410 | const { createLocalApi } = await import("./localApi"); |
| 384 | 411 | ||
| 385 | const api = createWsNativeApi(); | 412 | const api = createLocalApi(); |
| 386 | 413 | ||
| 387 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( | 414 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( |
| 388 | nextSettings, | 415 | nextSettings, |
| @@ -396,8 +423,8 @@ describe("wsNativeApi", () => { · 7 unmodified lines | |||
| 396 | const showContextMenu = vi.fn().mockResolvedValue("delete"); | 423 | const showContextMenu = vi.fn().mockResolvedValue("delete"); |
| 397 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); | 424 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); |
| 398 | 425 | ||
| 399 | const { createWsNativeApi } = await import("./wsNativeApi"); | 426 | const { createLocalApi } = await import("./localApi"); |
| 400 | const api = createWsNativeApi(); | 427 | const api = createLocalApi(); |
| 401 | const items = [{ id: "delete", label: "Delete" }] as const; | 428 | const items = [{ id: "delete", label: "Delete" }] as const; |
| 402 | 429 | ||
| 403 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); | 430 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); |
| @@ -406,9 +433,9 @@ describe("wsNativeApi", () => { · 2 unmodified lines | |||
| 406 | 433 | ||
| 407 | it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { | 434 | it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { |
| 408 | showContextMenuFallbackMock.mockResolvedValue("rename"); | 435 | showContextMenuFallbackMock.mockResolvedValue("rename"); |
| 409 | const { createWsNativeApi } = await import("./wsNativeApi"); | 436 | const { createLocalApi } = await import("./localApi"); |
| 410 | 437 | ||
| 411 | const api = createWsNativeApi(); | 438 | const api = createLocalApi(); |
| 412 | const items = [{ id: "rename", label: "Rename" }] as const; | 439 | const items = [{ id: "rename", label: "Rename" }] as const; |
| 413 | 440 | ||
| 414 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); | 441 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); |
| @@ -0,0 +1,94 @@ | |||
| 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; | ||
| 4 | |||
| 5 | import { __resetWsRpcAtomClientForTests } from "./rpc/client"; | ||
| 6 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | ||
| 7 | import { resetServerStateForTests } from "./rpc/serverState"; | ||
| 8 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; | ||
| 9 | import { getPrimaryWsRpcClientEntry, WsRpcClient, __resetWsRpcClientForTests } from "./wsRpcClient"; | ||
| 10 | import { showContextMenuFallback } from "./contextMenuFallback"; | ||
| 11 | |||
| 12 | let cachedApi: LocalApi | undefined; | ||
| 13 | |||
| 14 | export function createLocalApi( | ||
| 15 | rpcClient: WsRpcClient = getPrimaryWsRpcClientEntry().client, | ||
| 16 | ): LocalApi { | ||
| 17 | return { | ||
| 18 | dialogs: { | ||
| 19 | pickFolder: async () => { | ||
| 20 | if (!window.desktopBridge) return null; | ||
| 21 | return window.desktopBridge.pickFolder(); | ||
| 22 | }, | ||
| 23 | confirm: async (message) => { | ||
| 24 | if (window.desktopBridge) { | ||
| 25 | return window.desktopBridge.confirm(message); | ||
| 26 | } | ||
| 27 | return window.confirm(message); | ||
| 28 | }, | ||
| 29 | }, | ||
| 30 | shell: { | ||
| 31 | openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), | ||
| 32 | openExternal: async (url) => { | ||
| 33 | if (window.desktopBridge) { | ||
| 34 | const opened = await window.desktopBridge.openExternal(url); | ||
| 35 | if (!opened) { | ||
| 36 | throw new Error("Unable to open link."); | ||
| 37 | } | ||
| 38 | return; | ||
| 39 | } | ||
| 40 | |||
| 41 | window.open(url, "_blank", "noopener,noreferrer"); | ||
| 42 | }, | ||
| 43 | }, | ||
| 44 | contextMenu: { | ||
| 45 | show: async <T extends string>( | ||
| 46 | items: readonly ContextMenuItem<T>[], | ||
| 47 | position?: { x: number; y: number }, | ||
| 48 | ): Promise<T | null> => { | ||
| 49 | if (window.desktopBridge) { | ||
| 50 | return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>; | ||
| 51 | } | ||
| 52 | return showContextMenuFallback(items, position); | ||
| 53 | }, | ||
| 54 | }, | ||
| 55 | server: { | ||
| 56 | getConfig: rpcClient.server.getConfig, | ||
| 57 | refreshProviders: rpcClient.server.refreshProviders, | ||
| 58 | upsertKeybinding: rpcClient.server.upsertKeybinding, | ||
| 59 | getSettings: rpcClient.server.getSettings, | ||
| 60 | updateSettings: rpcClient.server.updateSettings, | ||
| 61 | }, | ||
| 62 | }; | ||
| 63 | } | ||
| 64 | |||
| 65 | export function readLocalApi(): LocalApi | undefined { | ||
| 66 | if (typeof window === "undefined") return undefined; | ||
| 67 | if (cachedApi) return cachedApi; | ||
| 68 | |||
| 69 | if (window.nativeApi) { | ||
| 70 | cachedApi = window.nativeApi; | ||
| 71 | return cachedApi; | ||
| 72 | } | ||
| 73 | |||
| 74 | cachedApi = createLocalApi(); | ||
| 75 | return cachedApi; | ||
| 76 | } | ||
| 77 | |||
| 78 | export function ensureLocalApi(): LocalApi { | ||
| 79 | const api = readLocalApi(); | ||
| 80 | if (!api) { | ||
| 81 | throw new Error("Local API not found"); | ||
| 82 | } | ||
| 83 | return api; | ||
| 84 | } | ||
| 85 | |||
| 86 | export async function __resetLocalApiForTests() { | ||
| 87 | cachedApi = undefined; | ||
| 88 | await __resetWsRpcAtomClientForTests(); | ||
| 89 | await __resetWsRpcClientForTests(); | ||
| 90 | resetGitStatusStateForTests(); | ||
| 91 | resetRequestLatencyStateForTests(); | ||
| 92 | resetServerStateForTests(); | ||
| 93 | resetWsConnectionStateForTests(); | ||
| 94 | } | ||
| @@ -0,0 +1,19 @@ | |||
| 1 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | ||
| 2 | import type { ScopedProjectRef } from "@t3tools/contracts"; | ||
| 3 | import type { Project } from "./types"; | ||
| 4 | |||
| 5 | export function deriveLogicalProjectKey( | ||
| 6 | project: Pick<Project, "environmentId" | "id" | "repositoryIdentity">, | ||
| 7 | ): string { | ||
| 8 | return ( | ||
| 9 | project.repositoryIdentity?.canonicalKey ?? | ||
| 10 | scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) | ||
| 11 | ); | ||
| 12 | } | ||
| 13 | |||
| 14 | export function deriveLogicalProjectKeyFromRef( | ||
| 15 | projectRef: ScopedProjectRef, | ||
| 16 | project: Pick<Project, "repositoryIdentity"> | null | undefined, | ||
| 17 | ): string { | ||
| 18 | return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); | ||
| 19 | } | ||
| @@ -1,31 +0,0 @@ | |||
| 1 | import type { NativeApi } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi"; | ||
| 4 | |||
| 5 | let cachedApi: NativeApi | undefined; | ||
| 6 | |||
| 7 | export function readNativeApi(): NativeApi | undefined { | ||
| 8 | if (typeof window === "undefined") return undefined; | ||
| 9 | if (cachedApi) return cachedApi; | ||
| 10 | |||
| 11 | if (window.nativeApi) { | ||
| 12 | cachedApi = window.nativeApi; | ||
| 13 | return cachedApi; | ||
| 14 | } | ||
| 15 | |||
| 16 | cachedApi = createWsNativeApi(); | ||
| 17 | return cachedApi; | ||
| 18 | } | ||
| 19 | |||
| 20 | export function ensureNativeApi(): NativeApi { | ||
| 21 | const api = readNativeApi(); | ||
| 22 | if (!api) { | ||
| 23 | throw new Error("Native API not found"); | ||
| 24 | } | ||
| 25 | return api; | ||
| 26 | } | ||
| 27 | |||
| 28 | export async function __resetNativeApiForTests() { | ||
| 29 | cachedApi = undefined; | ||
| 30 | await __resetWsNativeApiForTests(); | ||
| 31 | } | ||
| @@ -68,7 +68,7 @@ describe("deriveOrchestrationBatchEffects", () => { · 67 unmodified lines | |||
| 68 | }), | 68 | }), |
| 69 | ]); | 69 | ]); |
| 70 | 70 | ||
| 71 | expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); | 71 | expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]); |
| 72 | 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", () => { · 31 unmodified lines | |||
| 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", () => { · 14 unmodified lines | |||
| 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 | }); |
| @@ -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( · 62 unmodified lines | |||
| 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( · 4 unmodified lines | |||
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | return { | 88 | return { |
| 89 | clearPromotedDraftThreadIds, | 89 | promoteDraftThreadIds, |
| 90 | clearDeletedThreadIds, | 90 | clearDeletedThreadIds, |
| 91 | removeTerminalStateThreadIds, | 91 | removeTerminalStateThreadIds, |
| 92 | needsProviderInvalidation, | 92 | needsProviderInvalidation, |
| @@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' · 13 unmodified lines | |||
| 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({ · 19 unmodified lines | |||
| 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' { · 38 unmodified lines | |||
| 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) |
| @@ -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 { · 1 unmodified line | |||
| 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 { · 7 unmodified lines | |||
| 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 { · 3 unmodified lines | |||
| 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"; · 3 unmodified lines | |||
| 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<{ · 6 unmodified lines | |||
| 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( · 133 unmodified lines | |||
| 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() { · 11 unmodified lines | |||
| 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() { · 1 unmodified line | |||
| 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() { · 2 unmodified lines | |||
| 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() { · 30 unmodified lines | |||
| 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() { · 21 unmodified lines | |||
| 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() { · 14 unmodified lines | |||
| 371 | }, | 424 | }, |
| 372 | ); | 425 | ); |
| 373 | 426 | ||
| 374 | const applyEventBatch = (events: ReadonlyArray<OrchestrationEvent>) => { | 427 | const applyEventBatch = ( |
| 428 | events: ReadonlyArray<OrchestrationEvent>, | ||
| 429 | environmentId: EnvironmentId, | ||
| 430 | recovery: ReturnType<typeof createOrchestrationRecoveryCoordinator>, | ||
| 431 | ) => { | ||
| 375 | 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() { · 13 unmodified lines | |||
| 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() { · 1 unmodified line | |||
| 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, |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; |
| 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react"; | 2 | import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | 3 | ||
| 5 | import ChatView from "../components/ChatView"; | 4 | import ChatView from "../components/ChatView"; |
| 5 | import { threadHasStarted } from "../components/ChatView.logic"; | ||
| 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; |
| 7 | import { | 7 | import { |
| 8 | DiffPanelHeaderSkeleton, | 8 | DiffPanelHeaderSkeleton, |
| @@ -10,14 +10,16 @@ import { · 1 unmodified line | |||
| 10 | DiffPanelShell, | 10 | DiffPanelShell, |
| 11 | type DiffPanelMode, | 11 | type DiffPanelMode, |
| 12 | } from "../components/DiffPanelShell"; | 12 | } from "../components/DiffPanelShell"; |
| 13 | import { useComposerDraftStore } from "../composerDraftStore"; | 13 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; |
| 14 | import { | 14 | import { |
| 15 | type DiffRouteSearch, | 15 | type DiffRouteSearch, |
| 16 | parseDiffRouteSearch, | 16 | parseDiffRouteSearch, |
| 17 | stripDiffSearchParams, | 17 | stripDiffSearchParams, |
| 18 | } from "../diffRouteSearch"; | 18 | } from "../diffRouteSearch"; |
| 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 20 | import { useStore } from "../store"; | 20 | import { selectEnvironmentState, selectThreadByRef, useStore } from "../store"; |
| 21 | import { createThreadSelectorByRef } from "../storeSelectors"; | ||
| 22 | import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; | ||
| 21 | import { Sheet, SheetPopup } from "../components/ui/sheet"; | 23 | import { Sheet, SheetPopup } from "../components/ui/sheet"; |
| 22 | import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | 24 | import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; |
| 23 | 25 | ||
| @@ -161,39 +163,60 @@ const DiffPanelInlineSidebar = (props: { · 137 unmodified lines | |||
| 161 | }; | 163 | }; |
| 162 | 164 | ||
| 163 | function ChatThreadRouteView() { | 165 | function ChatThreadRouteView() { |
| 164 | const bootstrapComplete = useStore((store) => store.bootstrapComplete); | ||
| 165 | const navigate = useNavigate(); | 166 | const navigate = useNavigate(); |
| 166 | const threadId = Route.useParams({ | 167 | const threadRef = Route.useParams({ |
| 167 | select: (params) => ThreadId.makeUnsafe(params.threadId), | 168 | select: (params) => resolveThreadRouteRef(params), |
| 168 | }); | 169 | }); |
| 169 | const search = Route.useSearch(); | 170 | const search = Route.useSearch(); |
| 170 | const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); | 171 | const bootstrapComplete = useStore( |
| 172 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, | ||
| 173 | ); | ||
| 174 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | ||
| 175 | const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined); | ||
| 176 | const environmentHasServerThreads = useStore( | ||
| 177 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, | ||
| 178 | ); | ||
| 171 | const draftThreadExists = useComposerDraftStore((store) => | 179 | const draftThreadExists = useComposerDraftStore((store) => |
| 172 | Object.hasOwn(store.draftThreadsByThreadId, threadId), | 180 | threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, |
| 173 | ); | 181 | ); |
| 182 | const draftThread = useComposerDraftStore((store) => | ||
| 183 | threadRef ? store.getDraftThreadByRef(threadRef) : null, | ||
| 184 | ); | ||
| 185 | const environmentHasDraftThreads = useComposerDraftStore((store) => { | ||
| 186 | if (!threadRef) { | ||
| 187 | return false; | ||
| 188 | } | ||
| 189 | return store.hasDraftThreadsInEnvironment(threadRef.environmentId); | ||
| 190 | }); | ||
| 174 | const routeThreadExists = threadExists || draftThreadExists; | 191 | const routeThreadExists = threadExists || draftThreadExists; |
| 192 | const serverThreadStarted = threadHasStarted(serverThread); | ||
| 193 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; | ||
| 175 | const diffOpen = search.diff === "1"; | 194 | const diffOpen = search.diff === "1"; |
| 176 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | 195 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); |
| 177 | // TanStack Router keeps active route components mounted across param-only navigations | ||
| 178 | // unless remountDeps are configured, so this stays warm across thread switches. | ||
| 179 | const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); | 196 | const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); |
| 180 | const closeDiff = useCallback(() => { | 197 | const closeDiff = useCallback(() => { |
| 198 | if (!threadRef) { | ||
| 199 | return; | ||
| 200 | } | ||
| 181 | void navigate({ | 201 | void navigate({ |
| 182 | to: "/$threadId", | 202 | to: "/$environmentId/$threadId", |
| 183 | params: { threadId }, | 203 | params: buildThreadRouteParams(threadRef), |
| 184 | search: { diff: undefined }, | 204 | search: { diff: undefined }, |
| 185 | }); | 205 | }); |
| 186 | }, [navigate, threadId]); | 206 | }, [navigate, threadRef]); |
| 187 | const openDiff = useCallback(() => { | 207 | const openDiff = useCallback(() => { |
| 208 | if (!threadRef) { | ||
| 209 | return; | ||
| 210 | } | ||
| 188 | void navigate({ | 211 | void navigate({ |
| 189 | to: "/$threadId", | 212 | to: "/$environmentId/$threadId", |
| 190 | params: { threadId }, | 213 | params: buildThreadRouteParams(threadRef), |
| 191 | search: (previous) => { | 214 | search: (previous) => { |
| 192 | const rest = stripDiffSearchParams(previous); | 215 | const rest = stripDiffSearchParams(previous); |
| 193 | return { ...rest, diff: "1" }; | 216 | return { ...rest, diff: "1" }; |
| 194 | }, | 217 | }, |
| 195 | }); | 218 | }); |
| 196 | }, [navigate, threadId]); | 219 | }, [navigate, threadRef]); |
| 197 | 220 | ||
| 198 | useEffect(() => { | 221 | useEffect(() => { |
| 199 | if (diffOpen) { | 222 | if (diffOpen) { |
| @@ -202,17 +225,23 @@ function ChatThreadRouteView() { · 2 unmodified lines | |||
| 202 | }, [diffOpen]); | 225 | }, [diffOpen]); |
| 203 | 226 | ||
| 204 | useEffect(() => { | 227 | useEffect(() => { |
| 205 | if (!bootstrapComplete) { | 228 | if (!threadRef || !bootstrapComplete) { |
| 206 | return; | 229 | return; |
| 207 | } | 230 | } |
| 208 | 231 | ||
| 209 | if (!routeThreadExists) { | 232 | if (!routeThreadExists && environmentHasAnyThreads) { |
| 210 | void navigate({ to: "/", replace: true }); | 233 | void navigate({ to: "/", replace: true }); |
| 234 | } | ||
| 235 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); | ||
| 236 | |||
| 237 | useEffect(() => { | ||
| 238 | if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) { | ||
| 211 | return; | 239 | return; |
| 212 | } | 240 | } |
| 213 | }, [bootstrapComplete, navigate, routeThreadExists, threadId]); | 241 | finalizePromotedDraftThreadByRef(threadRef); |
| 242 | }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); | ||
| 214 | 243 | ||
| 215 | if (!bootstrapComplete || !routeThreadExists) { | 244 | if (!threadRef || !bootstrapComplete || !routeThreadExists) { |
| 216 | return null; | 245 | return null; |
| 217 | } | 246 | } |
| 218 | 247 | ||
| @@ -222,7 +251,11 @@ function ChatThreadRouteView() { · 3 unmodified lines | |||
| 222 | return ( | 251 | return ( |
| 223 | <> | 252 | <> |
| 224 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | 253 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 225 | <ChatView threadId={threadId} /> | 254 | <ChatView |
| 255 | environmentId={threadRef.environmentId} | ||
| 256 | threadId={threadRef.threadId} | ||
| 257 | routeKind="server" | ||
| 258 | /> | ||
| 226 | </SidebarInset> | 259 | </SidebarInset> |
| 227 | <DiffPanelInlineSidebar | 260 | <DiffPanelInlineSidebar |
| 228 | diffOpen={diffOpen} | 261 | diffOpen={diffOpen} |
| @@ -237,7 +270,11 @@ function ChatThreadRouteView() { · 8 unmodified lines | |||
| 237 | return ( | 270 | return ( |
| 238 | <> | 271 | <> |
| 239 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | 272 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 240 | <ChatView threadId={threadId} /> | 273 | <ChatView |
| 274 | environmentId={threadRef.environmentId} | ||
| 275 | threadId={threadRef.threadId} | ||
| 276 | routeKind="server" | ||
| 277 | /> | ||
| 241 | </SidebarInset> | 278 | </SidebarInset> |
| 242 | <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}> | 279 | <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}> |
| 243 | {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null} | 280 | {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null} |
| @@ -246,7 +283,7 @@ function ChatThreadRouteView() { · 2 unmodified lines | |||
| 246 | ); | 283 | ); |
| 247 | } | 284 | } |
| 248 | 285 | ||
| 249 | export const Route = createFileRoute("/_chat/$threadId")({ | 286 | export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ |
| 250 | validateSearch: (search) => parseDiffRouteSearch(search), | 287 | validateSearch: (search) => parseDiffRouteSearch(search), |
| 251 | search: { | 288 | search: { |
| 252 | middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])], | 289 | middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])], |
| @@ -0,0 +1,86 @@ | |||
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | ||
| 2 | import { useEffect, useMemo } from "react"; | ||
| 3 | import ChatView from "../components/ChatView"; | ||
| 4 | import { threadHasStarted } from "../components/ChatView.logic"; | ||
| 5 | import { useComposerDraftStore, DraftId } from "../composerDraftStore"; | ||
| 6 | import { SidebarInset } from "../components/ui/sidebar"; | ||
| 7 | import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; | ||
| 8 | import { useStore } from "../store"; | ||
| 9 | import { buildThreadRouteParams } from "../threadRoutes"; | ||
| 10 | |||
| 11 | function DraftChatThreadRouteView() { | ||
| 12 | const navigate = useNavigate(); | ||
| 13 | const { draftId: rawDraftId } = Route.useParams(); | ||
| 14 | const draftId = DraftId.makeUnsafe(rawDraftId); | ||
| 15 | const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); | ||
| 16 | const serverThread = useStore( | ||
| 17 | useMemo( | ||
| 18 | () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null), | ||
| 19 | [draftSession?.threadId], | ||
| 20 | ), | ||
| 21 | ); | ||
| 22 | const serverThreadStarted = threadHasStarted(serverThread); | ||
| 23 | const canonicalThreadRef = useMemo( | ||
| 24 | () => | ||
| 25 | draftSession?.promotedTo | ||
| 26 | ? serverThreadStarted | ||
| 27 | ? draftSession.promotedTo | ||
| 28 | : null | ||
| 29 | : serverThread | ||
| 30 | ? { | ||
| 31 | environmentId: serverThread.environmentId, | ||
| 32 | threadId: serverThread.id, | ||
| 33 | } | ||
| 34 | : null, | ||
| 35 | [draftSession?.promotedTo, serverThread, serverThreadStarted], | ||
| 36 | ); | ||
| 37 | |||
| 38 | useEffect(() => { | ||
| 39 | if (!canonicalThreadRef) { | ||
| 40 | return; | ||
| 41 | } | ||
| 42 | void navigate({ | ||
| 43 | to: "/$environmentId/$threadId", | ||
| 44 | params: buildThreadRouteParams(canonicalThreadRef), | ||
| 45 | replace: true, | ||
| 46 | }); | ||
| 47 | }, [canonicalThreadRef, navigate]); | ||
| 48 | |||
| 49 | useEffect(() => { | ||
| 50 | if (draftSession || canonicalThreadRef) { | ||
| 51 | return; | ||
| 52 | } | ||
| 53 | void navigate({ to: "/", replace: true }); | ||
| 54 | }, [canonicalThreadRef, draftSession, navigate]); | ||
| 55 | |||
| 56 | if (canonicalThreadRef) { | ||
| 57 | return ( | ||
| 58 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | ||
| 59 | <ChatView | ||
| 60 | environmentId={canonicalThreadRef.environmentId} | ||
| 61 | threadId={canonicalThreadRef.threadId} | ||
| 62 | routeKind="server" | ||
| 63 | /> | ||
| 64 | </SidebarInset> | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | |||
| 68 | if (!draftSession) { | ||
| 69 | return null; | ||
| 70 | } | ||
| 71 | |||
| 72 | return ( | ||
| 73 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | ||
| 74 | <ChatView | ||
| 75 | draftId={draftId} | ||
| 76 | environmentId={draftSession.environmentId} | ||
| 77 | threadId={draftSession.threadId} | ||
| 78 | routeKind="draft" | ||
| 79 | /> | ||
| 80 | </SidebarInset> | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | |||
| 84 | export const Route = createFileRoute("/_chat/draft/$draftId")({ | ||
| 85 | component: DraftChatThreadRouteView, | ||
| 86 | }); | ||
| @@ -1,3 +1,4 @@ | |||
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime"; | ||
| 1 | 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"; · 8 unmodified lines | |||
| 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() { · 2 unmodified lines | |||
| 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() { · 5 unmodified lines | |||
| 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() { · 4 unmodified lines | |||
| 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() { · 13 unmodified lines | |||
| 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 | ]); |
| @@ -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> = [ · 44 unmodified lines | |||
| 50 | }, | 51 | }, |
| 51 | ]; | 52 | ]; |
| 52 | 53 | ||
| 54 | const baseEnvironment = { | ||
| 55 | environmentId: EnvironmentId.makeUnsafe("environment-local"), | ||
| 56 | label: "Local environment", | ||
| 57 | platform: { | ||
| 58 | os: "darwin" as const, | ||
| 59 | arch: "arm64" as const, | ||
| 60 | }, | ||
| 61 | serverVersion: "0.0.0-test", | ||
| 62 | capabilities: { | ||
| 63 | repositoryIdentity: true, | ||
| 64 | }, | ||
| 65 | }; | ||
| 66 | |||
| 53 | 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", () => { · 136 unmodified lines | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 3 unmodified lines | |||
| 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"), |
| @@ -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"; · 7 unmodified lines | |||
| 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 { · 22 unmodified lines | |||
| 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 { · 4 unmodified lines | |||
| 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 { · 3 unmodified lines | |||
| 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 { · 25 unmodified lines | |||
| 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 { · 1 unmodified line | |||
| 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 { · 1 unmodified line | |||
| 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 { · 1 unmodified line | |||
| 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 { · 11 unmodified lines | |||
| 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( · 94 unmodified lines | |||
| 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", () => { · 7 unmodified lines | |||
| 287 | }), | 334 | }), |
| 288 | ); | 335 | ); |
| 289 | 336 | ||
| 290 | const next = syncServerReadModel(initialState, readModel); | 337 | const next = syncServerReadModel(initialState, readModel, localEnvironmentId); |
| 291 | 338 | ||
| 292 | 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", () => { · 18 unmodified lines | |||
| 312 | }), | 359 | }), |
| 313 | ); | 360 | ); |
| 314 | 361 | ||
| 315 | const next = syncServerReadModel(initialState, readModel); | 362 | const next = syncServerReadModel(initialState, readModel, localEnvironmentId); |
| 316 | 363 | ||
| 317 | 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", () => { · 6 unmodified lines | |||
| 325 | }), | 372 | }), |
| 326 | ); | 373 | ); |
| 327 | 374 | ||
| 328 | const next = syncServerReadModel(initialState, readModel); | 375 | const next = syncServerReadModel(initialState, readModel, localEnvironmentId); |
| 329 | 376 | ||
| 330 | 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", () => { · 9 unmodified lines | |||
| 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", () => { · 8 unmodified lines | |||
| 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", () => { · 6 unmodified lines | |||
| 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", () => { · 29 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 2 unmodified lines | |||
| 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", () => { · 6 unmodified lines | |||
| 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", () => { · 1 unmodified line | |||
| 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", () => { · 8 unmodified lines | |||
| 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", () => { · 21 unmodified lines | |||
| 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", () => { · 4 unmodified lines | |||
| 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", () => { · 6 unmodified lines | |||
| 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", () => { · 5 unmodified lines | |||
| 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", () => { · 12 unmodified lines | |||
| 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", () => { · 12 unmodified lines | |||
| 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", () => { · 9 unmodified lines | |||
| 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", () => { · 7 unmodified lines | |||
| 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", () => { · 9 unmodified lines | |||
| 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", () => { · 26 unmodified lines | |||
| 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", () => { · 38 unmodified lines | |||
| 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", () => { · 103 unmodified lines | |||
| 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", () => { · 52 unmodified lines | |||
| 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", () => { · 12 unmodified lines | |||
| 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({ |
| @@ -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 { · 6 unmodified lines | |||
| 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 { · 18 unmodified lines | |||
| 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 { · 13 unmodified lines | |||
| 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 = { · 13 unmodified lines | |||
| 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 · 88 unmodified lines | |||
| 169 | }; | 182 | }; |
| 170 | } | 183 | } |
| 171 | 184 | ||
| 172 | function mapProject(project: OrchestrationReadModel["projects"][number]): Project { | 185 | function mapProject( |
| 186 | project: OrchestrationReadModel["projects"][number], | ||
| 187 | environmentId: EnvironmentId, | ||
| 188 | ): Project { | ||
| 173 | 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 · 3 unmodified lines | |||
| 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 { · 19 unmodified lines | |||
| 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 | · 34 unmodified lines | |||
| 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 · 41 unmodified lines | |||
| 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): { · 73 unmodified lines | |||
| 377 | }; | 399 | }; |
| 378 | } | 400 | } |
| 379 | 401 | ||
| 380 | function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { | 402 | function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] { |
| 381 | 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[ · 6 unmodified lines | |||
| 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( · 7 unmodified lines | |||
| 404 | }); | 426 | }); |
| 405 | } | 427 | } |
| 406 | 428 | ||
| 407 | function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { | 429 | function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] { |
| 408 | 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 · 5 unmodified lines | |||
| 416 | }); | 438 | }); |
| 417 | } | 439 | } |
| 418 | 440 | ||
| 419 | function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { | 441 | function selectThreadTurnDiffSummaries( |
| 442 | state: EnvironmentState, | ||
| 443 | threadId: ThreadId, | ||
| 444 | ): TurnDiffSummary[] { | ||
| 420 | 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 · 5 unmodified lines | |||
| 428 | }); | 453 | }); |
| 429 | } | 454 | } |
| 430 | 455 | ||
| 431 | function getThread(state: AppState, threadId: ThreadId): Thread | undefined { | 456 | function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined { |
| 432 | 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 { · 11 unmodified lines | |||
| 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?: · 146 unmodified lines | |||
| 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 { · 267 unmodified lines | |||
| 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( · 7 unmodified lines | |||
| 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( · 5 unmodified lines | |||
| 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( · 66 unmodified lines | |||
| 989 | }; | 1018 | }; |
| 990 | } | 1019 | } |
| 991 | 1020 | ||
| 992 | export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { | 1021 | function getStoredEnvironmentState( |
| 1022 | state: AppState, | ||
| 1023 | environmentId: EnvironmentId, | ||
| 1024 | ): EnvironmentState { | ||
| 1025 | return state.environmentStateById[environmentId] ?? initialEnvironmentState; | ||
| 1026 | } | ||
| 1027 | |||
| 1028 | function commitEnvironmentState( | ||
| 1029 | state: AppState, | ||
| 1030 | environmentId: EnvironmentId, | ||
| 1031 | nextEnvironmentState: EnvironmentState, | ||
| 1032 | ): AppState { | ||
| 1033 | const currentEnvironmentState = state.environmentStateById[environmentId]; | ||
| 1034 | const environmentStateById = | ||
| 1035 | currentEnvironmentState === nextEnvironmentState | ||
| 1036 | ? state.environmentStateById | ||
| 1037 | : { | ||
| 1038 | ...state.environmentStateById, | ||
| 1039 | [environmentId]: nextEnvironmentState, | ||
| 1040 | }; | ||
| 1041 | |||
| 1042 | if (environmentStateById === state.environmentStateById) { | ||
| 1043 | return state; | ||
| 1044 | } | ||
| 1045 | |||
| 1046 | return { | ||
| 1047 | ...state, | ||
| 1048 | environmentStateById, | ||
| 1049 | }; | ||
| 1050 | } | ||
| 1051 | |||
| 1052 | function syncEnvironmentReadModel( | ||
| 1053 | state: EnvironmentState, | ||
| 1054 | readModel: OrchestrationReadModel, | ||
| 1055 | environmentId: EnvironmentId, | ||
| 1056 | ): EnvironmentState { | ||
| 993 | 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 · 2 unmodified lines | |||
| 1002 | }; | 1068 | }; |
| 1003 | } | 1069 | } |
| 1004 | 1070 | ||
| 1005 | export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { | 1071 | export function syncServerReadModel( |
| 1072 | state: AppState, | ||
| 1073 | readModel: OrchestrationReadModel, | ||
| 1074 | environmentId: EnvironmentId, | ||
| 1075 | ): AppState { | ||
| 1076 | return commitEnvironmentState( | ||
| 1077 | state, | ||
| 1078 | environmentId, | ||
| 1079 | syncEnvironmentReadModel( | ||
| 1080 | getStoredEnvironmentState(state, environmentId), | ||
| 1081 | readModel, | ||
| 1082 | environmentId, | ||
| 1083 | ), | ||
| 1084 | ); | ||
| 1085 | } | ||
| 1086 | |||
| 1087 | function applyEnvironmentOrchestrationEvent( | ||
| 1088 | state: EnvironmentState, | ||
| 1089 | event: OrchestrationEvent, | ||
| 1090 | environmentId: EnvironmentId, | ||
| 1091 | ): EnvironmentState { | ||
| 1006 | 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 · 39 unmodified lines | |||
| 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 · 29 unmodified lines | |||
| 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 · 360 unmodified lines | |||
| 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)), |
| @@ -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>( · 24 unmodified lines | |||
| 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) => |
108 more lines aren’t shown here.
This diff is large: its 379 lines aren’t shown here.
This diff is large: its 509 lines aren’t shown here.
This diff is large: its 68 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 260 lines aren’t shown here.
This diff is large: its 173 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 63 lines aren’t shown here.
This diff is large: its 203 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 118 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 201 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 3 lines aren’t shown here.
This diff is large: its 64 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 5 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 78 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 108 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 57 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.