From 5ec3fce794fb0e8116ddbab915c2c078acab347a Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Fri, 24 Jul 2026 20:57:19 +0200 Subject: [PATCH] fix: preserve fork adaptations across Tim update --- .../settings/DesktopClientSettings.test.ts | 2 +- .../desktop/src/shell/DesktopOpenWith.test.ts | 2 + apps/server/src/git/GitManager.test.ts | 1 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 112 ------------------ .../Services/ProjectionSnapshotQuery.ts | 10 ++ .../src/provider/Layers/CursorAdapter.ts | 101 +++++++--------- .../provider/Layers/OpenCodeAdapter.test.ts | 6 +- .../src/provider/Layers/OpenCodeAdapter.ts | 18 ++- .../provider/Layers/ProviderRegistry.test.ts | 14 ++- .../src/provider/Layers/ProviderService.ts | 31 +++++ .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/acp/AcpSessionRuntime.ts | 2 + apps/server/src/server.test.ts | 84 +++++++++++++ apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 2 + apps/server/src/vcs/GitVcsDriverCore.ts | 10 +- apps/server/src/ws.ts | 5 + apps/web/src/components/ChatView.tsx | 100 ++++++++-------- apps/web/src/components/Sidebar.logic.test.ts | 17 +-- apps/web/src/components/Sidebar.logic.ts | 22 +++- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/SidebarV2.tsx | 46 +------ .../src/components/ThreadStatusIndicators.tsx | 23 ++-- apps/web/src/components/board/Board.logic.ts | 3 +- apps/web/src/components/board/BoardCard.tsx | 6 +- apps/web/src/components/board/BoardColumn.tsx | 15 +-- apps/web/src/components/board/BoardView.tsx | 6 +- .../src/components/chat/ChatHeader.test.ts | 8 +- apps/web/src/components/chat/ChatHeader.tsx | 8 +- apps/web/src/components/chat/OpenInPicker.tsx | 30 ++++- packages/contracts/src/rpc.ts | 2 + packages/contracts/src/settings.test.ts | 12 ++ packages/contracts/src/settings.ts | 4 + 34 files changed, 364 insertions(+), 346 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index b7d7b0fe393..4c3c6a77811 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,7 +20,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], - glassOpacity: 80, + providerFavorites: [], openWithEntries: [ { id: OpenWithEntryId.make("terminal"), diff --git a/apps/desktop/src/shell/DesktopOpenWith.test.ts b/apps/desktop/src/shell/DesktopOpenWith.test.ts index 55dbafb8fbd..286a7f161a8 100644 --- a/apps/desktop/src/shell/DesktopOpenWith.test.ts +++ b/apps/desktop/src/shell/DesktopOpenWith.test.ts @@ -146,6 +146,8 @@ describe("DesktopOpenWith launch resolution", () => { it.effect("resolves CFBundleExecutable and reports missing bundle executables", () => Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Platform-gated native fixture. + if (process.platform !== "darwin") return; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-with-test-" }); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index df2e5f40809..9f35cc4c62d 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -638,6 +638,7 @@ const configureFailingCommitSigner = Effect.fn("configureFailingCommitSigner")(f { mode: 0o755 }, ); yield* runGit(repoDir, ["config", "commit.gpgSign", "true"]); + yield* runGit(repoDir, ["config", "gpg.format", "openpgp"]); yield* runGit(repoDir, ["config", "gpg.program", signerPath]); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 2ee35644f7c..bf9cd0670ab 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -274,6 +274,7 @@ describe("OrchestrationEngine", () => { getSessionStopContextById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.succeed(Option.none()), }), ), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f83a57b7dc6..0f603a31279 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -156,9 +156,6 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; - readonly startSessionEffect?: ( - session: ProviderSession, - ) => Effect.Effect; readonly deferReactorStart?: boolean; readonly providerBindings?: ReadonlyArray; readonly providerBindingsMap?: Map; @@ -550,115 +547,6 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - effectIt.effect("projects starting before a slow provider session finishes", () => - Effect.gen(function* () { - const releaseStart = yield* Deferred.make(); - const harness = yield* Effect.promise(() => - createHarness({ - startSessionEffect: (session) => Deferred.await(releaseStart).pipe(Effect.as(session)), - }), - ); - const now = "2026-01-01T00:00:00.000Z"; - - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-slow-provider"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-slow-provider"), - role: "user", - text: "start slowly", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }); - - yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); - const duringStartup = yield* Effect.promise(() => harness.readModel()); - expect( - duringStartup.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session - ?.status, - ).toBe("starting"); - expect(harness.sendTurn).not.toHaveBeenCalled(); - - yield* Deferred.succeed(releaseStart, undefined); - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); - }), - ); - - effectIt.effect("settles a failed provider startup and allows a clean retry", () => - Effect.gen(function* () { - let failStartup = true; - const harness = yield* Effect.promise(() => - createHarness({ - startSessionEffect: (session) => - failStartup - ? Effect.fail( - new ProviderAdapterRequestError({ - provider: "codex", - method: "thread.start", - detail: "deterministic startup failure", - }), - ) - : Effect.succeed(session), - }), - ); - const now = "2026-01-01T00:00:00.000Z"; - - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-failure"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-provider-failure"), - role: "user", - text: "fail once", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }); - - yield* Effect.promise(() => - waitFor(async () => { - const readModel = await harness.readModel(); - return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session - ?.status === "error" - ); - }), - ); - let readModel = yield* Effect.promise(() => harness.readModel()); - let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.lastError).toContain("deterministic startup failure"); - expect(harness.sendTurn).not.toHaveBeenCalled(); - - failStartup = false; - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-retry"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-provider-retry"), - role: "user", - text: "retry", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: "2026-01-01T00:00:01.000Z", - }); - - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); - readModel = yield* Effect.promise(() => harness.readModel()); - thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.status).toBe("starting"); - expect(thread?.session?.lastError).toBeNull(); - }), - ); it("replays a persisted pending turn start exactly once on startup", async () => { const harness = await createHarness({ deferReactorStart: true }); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 19c1f719140..de0c0deea5d 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -189,6 +189,16 @@ export interface ProjectionSnapshotQueryShape { threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Cursor-paginated load of a thread's older activities (lazy-load / infinite + * scroll). Returns the page of activities immediately older than the provided + * sequence or unsequenced activity cursor, ascending, plus whether older ones + * remain. + */ + readonly getThreadActivitiesPage: ( + input: OrchestrationGetThreadActivitiesInput, + ) => Effect.Effect; + /** * Read a thread's lifecycle markers regardless of its deleted/archived * state. Lets callers that got no active row distinguish a thread that was diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 3ef3a017b44..8f0329c1856 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -598,8 +598,18 @@ export function makeAcpCliAdapter( threadId: input.threadId, cwd, environment: options?.environment ?? process.env, - }); - const cursorModelSelection = + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: definition.provider, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const providerModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { @@ -635,61 +645,38 @@ export function makeAcpCliAdapter( : initialSettings; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeCursorAcpRuntime({ - cursorSettings: effectiveCursorSettings, - environment, - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); - const started = yield* Effect.gen(function* () { - yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), + const acp = yield* definition + .makeRuntime(effectiveSettings, { + environment, + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }) + .pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, detail: cause.message, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 562295fc59a..aeeb89e426c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -55,13 +55,12 @@ type MessageEntry = { const runtimeMock = { state: { startCalls: [] as string[], + sessionCreateCalls: [] as Array<{ baseUrl: string; input: unknown }>, connectCalls: [] as Array<{ serverUrl?: string | null; environment?: NodeJS.ProcessEnv; cwd?: string; }>, - sessionCreateUrls: [] as string[], - sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as Array<{ sessionID: string; directory?: string }>, closeCalls: [] as string[], @@ -76,9 +75,8 @@ const runtimeMock = { }, reset() { this.state.startCalls.length = 0; + this.state.sessionCreateCalls.length = 0; this.state.connectCalls.length = 0; - this.state.sessionCreateUrls.length = 0; - this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 28f665a2b9d..11b7ddfd988 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -17,7 +17,6 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -528,10 +527,7 @@ export function makeOpenCodeAdapter( const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const sameDirectory = (left: string, right: string) => - isSameOpenCodeDirectory(fileSystem, path, left, right); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -1192,8 +1188,18 @@ export function makeOpenCodeAdapter( threadId: input.threadId, cwd: directory, environment: options?.environment ?? process.env, - }); - const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const resumeSessionId = readOpenCodeResumeSessionId(input.resumeCursor); const existing = sessions.get(input.threadId); if (existing) { yield* stopOpenCodeContext(existing); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 703e1a85f7c..cfa23cc37e9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1567,7 +1567,10 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { const initialCodex = initialProviders.find((provider) => provider.instanceId === "codex"); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - assert.deepStrictEqual(spawnedCommands, [firstMissing]); + assert.deepStrictEqual( + spawnedCommands.filter((command) => command !== "kimi"), + [firstMissing], + ); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1604,7 +1607,10 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); + assert.deepStrictEqual( + spawnedCommands.filter((command) => command !== "kimi"), + [firstMissing, secondMissing], + ); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); @@ -1756,6 +1762,7 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { "codex", "cursor", "grok", + "kimi", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); @@ -1832,7 +1839,7 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { ), ); - it.effect("includes Claude Fable 5 on supported Claude Code versions", () => + it.effect("keeps Claude Opus 4.8 first when Fable 5 is supported", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, @@ -1840,6 +1847,7 @@ it.layer(TestLayer)("ProviderRegistry", (it) => { ); const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); assert.strictEqual(fable5?.name, "Claude Fable 5"); + assert.strictEqual(status.models[0]?.slug, "claude-opus-4-8"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 48e7c90d2a0..b7870fd105e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -151,6 +151,37 @@ function toRuntimePayloadFromSession( }; } +function readPersistedModelSelection( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): ModelSelection | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined; + return isModelSelection(raw) ? raw : undefined; +} + +function readPersistedCwd( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): string | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const rawCwd = "cwd" in runtimePayload ? runtimePayload.cwd : undefined; + if (typeof rawCwd !== "string") return undefined; + const trimmed = rawCwd.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeProviderCwd(cwd: string): string { + const trimmed = cwd.trim(); + return trimmed.length > 1 ? trimmed.replace(/[\\/]+$/, "") : trimmed; +} + +function providerCwdMatches(actual: string | undefined, expected: string | undefined): boolean { + if (expected === undefined) return true; + return actual !== undefined && normalizeProviderCwd(actual) === normalizeProviderCwd(expected); +} const dieOnMissingBindingInstanceId = ( operation: string, payload: { diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index b070ccf079d..b3d49a0ed70 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -228,6 +228,7 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.die("unused"), }), ), diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 01919c20268..21c0f564abe 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -68,6 +68,7 @@ export interface AcpSpawnInput { readonly args: ReadonlyArray; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; + readonly forceKillAfter?: Duration.Input; readonly extendEnv?: boolean; } @@ -381,6 +382,7 @@ export const make = ( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), ...(options.spawn.env ? { env: options.spawn.env, extendEnv } : {}), + ...(options.spawn.forceKillAfter ? { forceKillAfter: options.spawn.forceKillAfter } : {}), shell: spawnCommand.shell, }), ) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 21b45c7aba9..0c66c62b5e0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8,6 +8,7 @@ import { AuthAccessTokenType, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, + AI_USAGE_UNAVAILABLE, CommandId, DEFAULT_SERVER_SETTINGS, EnvironmentId, @@ -78,6 +79,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as GrokTranscriptResync from "./externalSessions/GrokTranscriptResync.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -119,6 +121,8 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; +import * as AiUsageMonitorModule from "./aiUsage/AiUsageMonitor.ts"; +import * as HostResourceProbe from "./diagnostics/HostResourceProbe.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -759,10 +763,12 @@ const buildAppUnderTest = (options?: { getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), getThreadLifecycleById: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), getSessionStopContextById: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), @@ -7299,6 +7305,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("resumes a replayed bootstrap after its thread was already created", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const threadId = ThreadId.make("thread-bootstrap-replay"); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.suspend(() => { + dispatchedCommands.push(command); + return command.type === "thread.create" + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: "thread.create", + detail: `Thread '${threadId}' already exists and cannot be created twice.`, + }), + ) + : Effect.succeed({ sequence: dispatchedCommands.length }); + }), + readEvents: () => Stream.empty, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + }), + ), + ), + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-replay"), + threadId, + message: { + messageId: MessageId.make("msg-bootstrap-replay"), + role: "user", + text: "hello after reconnect", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Replay", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 2); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 27f58082bfb..0c05f849e72 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -175,6 +175,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService.AnalyticsService, { @@ -240,6 +241,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -286,6 +288,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -338,6 +341,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 5371f4a05cc..86e4dce674f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -19,6 +19,7 @@ import { import { isCommitSigningFailureStderr, makeGitVcsDriverCore, + redactGitOutput, splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -838,6 +839,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); yield* fileSystem.chmod(signerPath, 0o755); yield* git(cwd, ["config", "commit.gpgSign", "true"]); + yield* git(cwd, ["config", "gpg.format", "openpgp"]); yield* git(cwd, ["config", "gpg.program", signerPath]); yield* writeTextFile(cwd, "signed.txt", "sign me\n"); yield* git(cwd, ["add", "signed.txt"]); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e994632c014..29bc26aa34f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -745,6 +745,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + const onStdoutLine = input.progress?.onStdoutLine; + const onStderrLine = input.progress?.onStderrLine; const [stdout, stderr, exitCode] = yield* Effect.all( [ collectOutput( @@ -752,14 +754,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* child.stdout, maxOutputBytes, appendTruncationMarker, - input.progress?.onStdoutLine, + onStdoutLine + ? (line) => trace2Monitor.flush.pipe(Effect.andThen(onStdoutLine(line))) + : undefined, ), collectOutput( commandInput, child.stderr, maxOutputBytes, appendTruncationMarker, - input.progress?.onStderrLine, + onStderrLine + ? (line) => trace2Monitor.flush.pipe(Effect.andThen(onStderrLine(line))) + : undefined, ), child.exitCode.pipe( Effect.mapError( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a49e0bb920b..f310a1d825d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,6 +124,11 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { + isValidOmegentT3ProductHandshake, + OMEGENT_T3_CLIENT_REQUIRED_MESSAGE, + parseProductHandshakeFromSearchParams, +} from "@t3tools/shared/productFamily"; import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 150d7df62eb..10ec510ae89 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1315,6 +1315,9 @@ function ChatViewContent(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); + const [pendingWorktreeThreadIds, setPendingWorktreeThreadIds] = useState>( + () => new Set(), + ); const [ pendingServerThreadReuseBaseBranchByThreadId, setPendingServerThreadReuseBaseBranchByThreadId, @@ -2489,8 +2492,7 @@ function ChatViewContent(props: ChatViewProps) { }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const activeServerConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const availableEditors = activeServerConfig?.availableEditors ?? []; + const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no @@ -4951,51 +4953,43 @@ function ChatViewContent(props: ChatViewProps) { failure = turnAttachmentsResult; } - const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise); - if (failure === null && turnAttachmentsResult._tag === "Failure") { - failure = turnAttachmentsResult; - } - - let turnStartSucceeded = false; - if (failure === null && turnAttachmentsResult._tag === "Success") { - const bootstrap = - isLocalDraftThread || baseBranchForWorktree - ? { - ...(isLocalDraftThread - ? { - createThread: { - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, - createdAt: activeThread.createdAt, - }, - } - : {}), - ...(baseBranchForWorktree - ? { - prepareWorktree: { - projectCwd: activeProject.workspaceRoot, - baseBranch: baseBranchForWorktree, - ...(reuseBaseBranch - ? { reuseBaseBranch: true } - : { - branch: buildTemporaryWorktreeBranchName(randomHex), - ...(startFromOrigin ? { startFromOrigin: true } : {}), - }), - }, - runSetupScript: true, - } - : {}), - } - : undefined; - beginLocalDispatch({ preparingWorktree: false }); - const startResult = await startThreadTurn({ - environmentId, - input: { + if (failure === null && turnAttachmentsResult._tag === "Success") { + const bootstrap = + isLocalDraftThread || baseBranchForWorktree + ? { + ...(isLocalDraftThread + ? { + createThread: { + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + } + : {}), + ...(baseBranchForWorktree + ? { + prepareWorktree: { + projectCwd: activeProject.workspaceRoot, + baseBranch: baseBranchForWorktree, + ...(reuseBaseBranch + ? { reuseBaseBranch: true } + : { + branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), + }), + }, + runSetupScript: true, + } + : {}), + } + : undefined; + const queuedTurnInput = { + commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, @@ -5748,7 +5742,9 @@ function ChatViewContent(props: ChatViewProps) { newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), reuseBaseBranch: false, - ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + ...(target !== "current-worktree" && draftThread?.worktreePath + ? { worktreePath: null } + : {}), }); } scheduleComposerFocus(); @@ -6230,9 +6226,11 @@ function ChatViewContent(props: ChatViewProps) { onStartFromOriginChange={onStartFromOriginChange} reuseBaseBranch={reuseBaseBranch} onReuseBaseBranchChange={onReuseBaseBranchChange} - {...(canOverrideServerThreadEnvMode - ? { effectiveEnvModeOverride: envMode } - : {})} + {...(isPreparingWorktreeUi + ? { effectiveEnvModeOverride: "worktree" as const } + : canOverrideServerThreadEnvMode + ? { effectiveEnvModeOverride: envMode } + : {})} {...(canOverrideServerThreadEnvMode ? { activeThreadBranchOverride: activeThreadBranch, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 37cb04c4de9..ba81d891ebf 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -4,6 +4,7 @@ import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, buildSidebarV2ThreadContextMenuItems, + buildThreadContextMenuItems, createThreadJumpHintVisibilityController, formatWorktreeGroupLabel, getSidebarThreadIdsToPrewarm, @@ -27,7 +28,6 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarV2Status, - resolveSidebarV2TopStatus, resolveThreadStatusPill, resolveWorkingStartedAt, formatWorkingDurationLabel, @@ -1069,21 +1069,6 @@ describe("resolveSidebarV2Status", () => { }); }); -describe("resolveSidebarV2TopStatus", () => { - it("labels ready threads Done only when they carry an unread completion", () => { - expect(resolveSidebarV2TopStatus({ status: "ready", isUnread: true })).toMatchObject({ - label: "Done", - icon: "done", - }); - expect(resolveSidebarV2TopStatus({ status: "ready", isUnread: false })).toBeNull(); - // Unread only matters for ready threads; active statuses keep their label. - expect(resolveSidebarV2TopStatus({ status: "working", isUnread: true })).toMatchObject({ - label: "Working", - icon: "working", - }); - }); -}); - describe("sortThreadsForSidebarV2", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 577432574cb..ae4559e47ea 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -60,6 +60,25 @@ export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; // stays behind an explicit Show more. Shared by SidebarV2 and the board. export const SETTLED_TAIL_INITIAL_COUNT = 10; export const SETTLED_TAIL_PAGE_COUNT = 25; +export type SidebarNewThreadEnvMode = "local" | "worktree"; +export type SidebarThreadWorktreeSection = + | { + kind: "thread"; + thread: SidebarThreadSummary; + /** Resolved checkout path for PR/git status when this thread is not grouped. */ + checkoutPath?: string; + } + | { + kind: "worktree"; + key: string; + label: string; + branch: string | null; + checkoutPath: string; + source: "local" | "worktree"; + worktreePath: string | null; + threads: SidebarThreadSummary[]; + }; + type SidebarProject = { id: string; title: string; @@ -796,9 +815,6 @@ export interface SidebarV2TopStatus { className: string; } -// The v2 indicator presentation: colored label text (with an icon only for -// "in motion" and "done") instead of the v1 dot pill. Ready threads stay -// unlabeled unless they carry an unread completion, which surfaces as "Done". export function resolveSidebarV2TopStatus(input: { status: SidebarV2Status; isUnread: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a6dca659794..be6a48d18bb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { LoaderIcon, PinIcon, SearchIcon, + SettingsIcon, SquareKanbanIcon, SquarePenIcon, TerminalIcon, @@ -191,7 +192,6 @@ import { buildSidebarThreadWorktreeSections, archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, - buildThreadContextMenuItems, getSidebarThreadIdsToPrewarm, resolveSidebarThreadPrewarmLimit, resolveAdjacentThreadId, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index a9b348c804c..58a11a8af87 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -31,7 +31,6 @@ import { PlusIcon, SearchIcon, ServerIcon, - SquareKanbanIcon, SquarePenIcon, Trash2Icon, Undo2Icon, @@ -47,7 +46,7 @@ import { type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; -import { useLocation, useParams, useRouter } from "@tanstack/react-router"; +import { useParams, useRouter } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -2102,13 +2101,6 @@ export default function SidebarV2() { modelPickerOpen: isModelPickerOpen(), }, }); - if (command === "board.open") { - event.preventDefault(); - event.stopPropagation(); - if (isMobile) setOpenMobile(false); - void router.navigate({ to: "/board" }); - return; - } const navigateToThreadKey = (targetThreadKey: string | null) => { if (!targetThreadKey) return false; const targetThread = threadByKey.get(targetThreadKey); @@ -2136,14 +2128,11 @@ export default function SidebarV2() { window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ - isMobile, keybindings, navigateToThread, orderedThreadKeys, routeTerminalOpen, routeThreadKey, - router, - setOpenMobile, threadByKey, ]); @@ -2185,20 +2174,12 @@ export default function SidebarV2() { openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); - const pathname = useLocation({ select: (l) => l.pathname }); - const isBoardActive = pathname === "/board"; - const handleBoardClick = useCallback(() => { - if (isMobile) setOpenMobile(false); - void router.navigate({ to: "/board" }); - }, [isMobile, router, setOpenMobile]); - const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to // chat.new, no platform gating — web users have working shortcuts too. const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); - const boardShortcutLabel = shortcutLabelForCommand(keybindings, "board.open"); return ( <> @@ -2226,31 +2207,6 @@ export default function SidebarV2() { ) : null} -
- - - } - > - - - - {boardShortcutLabel ? `Board (${boardShortcutLabel})` : "Board"} - - -
}) { return ( @@ -439,11 +441,6 @@ export function ThreadSettledIndicator({ thread }: { thread: Pick
diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx index a06a82c4d56..9a878847ffd 100644 --- a/apps/web/src/components/board/BoardView.tsx +++ b/apps/web/src/components/board/BoardView.tsx @@ -319,11 +319,7 @@ function BoardContent() { const keys = new Set(); for (const thread of filteredThreads) { const changeRequestState = - resolveThreadPr({ - threadBranch: thread.branch, - hasDedicatedWorktree: thread.worktreePath != null, - gitStatus: getThreadGitContext(thread).gitStatus, - })?.state ?? null; + resolveThreadPr(thread.branch, getThreadGitContext(thread).gitStatus)?.state ?? null; if ( isThreadSettledForDisplay(thread, { serverConfigs, diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index 891fa7c593c..dbe05f2e477 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -24,24 +24,24 @@ describe("shouldShowOpenInPicker", () => { ).toBe(true); }); - it("keeps built-in applications visible when hosted static mode has no primary environment", () => { + it("hides the picker when hosted static mode has no primary environment", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId: null, }), - ).toBe(true); + ).toBe(false); }); - it("keeps built-in applications visible for remote environments", () => { + it("hides the picker for remote environments", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId, }), - ).toBe(true); + ).toBe(false); }); it("hides the picker when there is no active project", () => { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f9aed30e032..9d7d0b049d7 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -63,7 +63,11 @@ export function shouldShowOpenInPicker(input: { readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; }): boolean { - return Boolean(input.activeProjectName); + return ( + Boolean(input.activeProjectName) && + input.primaryEnvironmentId !== null && + input.activeThreadEnvironmentId === input.primaryEnvironmentId + ); } function encodeRemotePath(path: string): string { @@ -139,7 +143,7 @@ export const ChatHeader = memo(function ChatHeader({ const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, - primaryEnvironmentId: null, + primaryEnvironmentId, }); const remoteVscodeTarget = useMemo( () => diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 2c635a0a0f0..1febd75649b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -34,7 +34,7 @@ import { type OpenWithOption, } from "../../openWith"; import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; -import { ensureLocalApi } from "../../localApi"; +import { ensureLocalApi, readLocalApi } from "../../localApi"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -90,6 +90,26 @@ import { } from "../JetBrainsIcons"; import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; +function desktopEditorUrlScheme(editor: EditorId): string | null { + switch (editor) { + case "vscode": + return "vscode"; + case "vscode-insiders": + return "vscode-insiders"; + case "cursor": + return "cursor"; + default: + return null; + } +} + +export function resolveDesktopEditorUri(editor: EditorId, cwd: string): string | null { + const scheme = desktopEditorUrlScheme(editor); + if (!scheme || !cwd.startsWith("/")) return null; + const encodedPath = cwd.split("/").map(encodeURIComponent).join("/"); + return `${scheme}://file${encodedPath}?windowId=_blank`; +} + type BuiltinPresentation = { readonly label: string; readonly Icon: Icon; @@ -362,6 +382,14 @@ export const OpenInPicker = memo(function OpenInPicker({ } return; } + const localApi = readLocalApi(); + if (localApi) { + const uri = resolveDesktopEditorUri(option.id, openInCwd); + if (uri) { + await localApi.shell.openExternal(uri); + return; + } + } const result = await openInEditorMutation({ environmentId, input: { cwd: openInCwd, editor: option.id }, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c695c8f06ad..ade7e522624 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -38,6 +38,8 @@ import { VcsStatusInput, VcsStatusResult, VcsStatusStreamEvent, + VcsResolveBranchChangeRequestInput, + VcsResolveBranchChangeRequestResult, WorktreeCleanupInput, WorktreeCleanupPreviewInput, WorktreeCleanupPreviewResult, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1c0228c2288..6446d797119 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -39,6 +39,18 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebarHideProviderIcons", () => { + it("defaults to false", () => { + expect(decodeClientSettings({}).sidebarHideProviderIcons).toBe(false); + }); + + it("round-trips an explicit value", () => { + expect(decodeClientSettings({ sidebarHideProviderIcons: true }).sidebarHideProviderIcons).toBe( + true, + ); + }); +}); + describe("ClientSettings worktree removal confirmation", () => { it("defaults confirmation on for existing settings", () => { expect(decodeClientSettings({}).confirmWorktreeRemoval).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4a75034770b..e8f5e444ed7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -90,6 +90,9 @@ export const ClientSettingsSchema = Schema.Struct({ model: TrimmedNonEmptyString, }), ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + providerFavorites: Schema.Array(ProviderInstanceId).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), openWithEntries: OpenWithEntries.pipe(Schema.withDecodingDefault(Effect.succeed([]))), preferredOpenWith: Schema.NullOr(OpenWithEntryRef).pipe( Schema.withDecodingDefault(Effect.succeed(null)), @@ -629,6 +632,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + providerFavorites: Schema.optionalKey(Schema.Array(ProviderInstanceId)), openWithEntries: Schema.optionalKey(OpenWithEntries), preferredOpenWith: Schema.optionalKey(Schema.NullOr(OpenWithEntryRef)), providerModelPreferences: Schema.optionalKey(