From 590103ee7f1841c3a433b3f35aeffa6ad1878c86 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 13 Jul 2026 19:57:26 -0700 Subject: [PATCH 1/4] Surface-derived workspace defaults, pinned worktree bases, and worktree registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the new-thread workspace model: every new thread starts from the resolved default workspace mode instead of inheriting the previous thread's choices, and worktree threads pin a fresh remote base with visible provenance. Surface-derived defaults - New `deriveThreadEnvModeFromSurface` setting (default on): attached surfaces (desktop app, loopback browser on a host-managed environment) default to the current checkout; detached surfaces (remote browser, mobile, relay/SSH) default to a fresh worktree. Explicit `defaultThreadEnvMode` still pins a mode globally when derivation is off, keeping the wire value decodable by older clients. - Sticky per-draft inheritance retired: sidebar/new-thread actions no longer seed mode/branch/worktree from the active thread. `chat.newLocal` remains the explicit current-checkout exception (draft-only). - Per-project override (`projectThreadEnvModeOverrides` client setting) via an explicit "Always … for this project" action in the workspace picker — the only sanctioned way a composer choice retrains the default. Fresh worktree bases without a blocking fetch - `WorktreeBasePlanner`: fetch within the 30s freshness window counts as fresh; concurrent fetches dedupe per repository; a failed fetch with a successful fetch within the last hour proceeds on the last-known remote commit (fail-visible, "stale" provenance); otherwise the bootstrap fails closed and never silently falls back to a local ref. - Composer prefetch: worktree-mode drafts fire `vcs.prefetchRemote` on open so the send-time check almost always hits the fresh window. - `newWorktreesStartFromOrigin` now defaults on. Provenance and registry - Bootstrap records a `worktree.base-pinned` thread activity (base ref, sha, provenance, fetch time); the branch toolbar renders the pinned fact ("Worktree · a1b2c3f") with provenance on hover. - `WorktreeRegistry` persists thread↔worktree records to `stateDir/worktree-registry.json`, updated at bootstrap and on thread deletion — groundwork for lifecycle/GC tooling. - Deterministic branch naming: worktree branches derive from the thread id (`t3code/`), with collision suffixing server-side. Composer UX - Dirty-checkout disclosure both directions with one-click mode switch. - Workspace picker labels choices as "this draft only". Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 + .../threads/new-task-flow-provider.tsx | 32 ++- .../features/threads/use-project-actions.ts | 4 +- .../src/state/use-thread-outbox-drain.ts | 4 +- apps/server/src/config.ts | 2 + apps/server/src/git/WorktreeBasePlanner.ts | 200 ++++++++++++++++++ apps/server/src/git/WorktreeRegistry.ts | 148 +++++++++++++ .../Layers/ThreadDeletionReactor.ts | 4 + apps/server/src/server.test.ts | 56 ++++- apps/server/src/server.ts | 10 +- apps/server/src/vcs/GitVcsDriverCore.ts | 36 +++- apps/server/src/ws.ts | 113 ++++++++-- .../components/BranchToolbar.logic.test.ts | 71 +++++++ .../web/src/components/BranchToolbar.logic.ts | 62 +++++- apps/web/src/components/BranchToolbar.tsx | 138 ++++++++---- .../BranchToolbarEnvModeSelector.tsx | 59 +++++- apps/web/src/components/ChatView.tsx | 81 ++++++- apps/web/src/components/Sidebar.logic.test.ts | 70 +----- apps/web/src/components/Sidebar.logic.ts | 45 +--- apps/web/src/components/Sidebar.tsx | 69 +----- .../components/settings/SettingsPanels.tsx | 36 +++- apps/web/src/hooks/useHandleNewThread.ts | 33 ++- apps/web/src/lib/chatThreadActions.test.ts | 40 +--- apps/web/src/lib/chatThreadActions.ts | 25 +-- apps/web/src/lib/threadSurface.ts | 53 +++++ packages/client-runtime/src/state/vcs.ts | 6 + packages/contracts/src/git.ts | 22 ++ packages/contracts/src/rpc.ts | 10 + packages/contracts/src/settings.test.ts | 25 ++- packages/contracts/src/settings.ts | 52 ++++- packages/shared/package.json | 4 + packages/shared/src/git.test.ts | 27 +++ packages/shared/src/git.ts | 18 ++ packages/shared/src/threadEnvMode.test.ts | 49 +++++ packages/shared/src/threadEnvMode.ts | 35 +++ 35 files changed, 1308 insertions(+), 332 deletions(-) create mode 100644 apps/server/src/git/WorktreeBasePlanner.ts create mode 100644 apps/server/src/git/WorktreeRegistry.ts create mode 100644 apps/web/src/lib/threadSurface.ts create mode 100644 packages/shared/src/threadEnvMode.test.ts create mode 100644 packages/shared/src/threadEnvMode.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..919135f10d5 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], providerModelPreferences: {}, + projectThreadEnvModeOverrides: {}, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ee2bde9d971..42673546762 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -12,9 +12,11 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, MessageId, ThreadId, } from "@t3tools/contracts"; +import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -345,10 +347,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; - const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; + // Mobile is a detached surface — there is no local checkout in front of + // the user — so surface derivation (the default) resolves to an isolated + // worktree pinned to the latest remote base. An explicit + // defaultThreadEnvMode in the environment settings still applies, and + // "Local" remains an explicit per-draft exception. + const environmentSettings = selectedEnvironmentServerConfig?.settings; + const defaultWorkspaceMode = resolveDefaultThreadEnvMode({ + deriveFromSurface: + environmentSettings?.deriveThreadEnvModeFromSurface ?? + DEFAULT_SERVER_SETTINGS.deriveThreadEnvModeFromSurface, + configuredMode: + environmentSettings?.defaultThreadEnvMode ?? DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, + surface: "detached", + }); + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; - const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false; + const startFromOrigin = + selectedProjectDraft.workspaceSelection?.startFromOrigin ?? + (workspaceMode === "worktree" && + (environmentSettings?.newWorktreesStartFromOrigin ?? + DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin)); const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; @@ -667,7 +687,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { return null; } const workspaceSelection = draft.workspaceSelection; - const mode = workspaceSelection?.mode ?? "local"; + const mode = workspaceSelection?.mode ?? defaultWorkspaceMode; + const pendingStartFromOrigin = + workspaceSelection?.startFromOrigin ?? (mode === "worktree" && startFromOrigin); // When the selection is the stand-in built from the queued snapshot, // persist the original (possibly absent) snapshot values — the // stand-in's placeholder title/workspaceRoot must never be written back @@ -696,17 +718,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceMode: mode, branch: workspaceSelection?.branch ?? null, worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), - ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}), + ...(pendingStartFromOrigin ? { startFromOrigin: true } : {}), }, createdAt: metadata.createdAt, }; }, [ + defaultWorkspaceMode, editingPendingProject, editingPendingTask, selectedModel, selectedProject, selectedProjectDraftKey, + startFromOrigin, ], ); diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a9..62510587fd9 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -9,7 +9,7 @@ import { type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { buildThreadWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -74,7 +74,7 @@ export function useCreateProjectThread() { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: input.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchName: buildThreadWorktreeBranchName(metadata.threadId, randomHex), }), }); if (AsyncResult.isFailure(result)) { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index f4de9c39492..206aad7cc74 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -10,7 +10,7 @@ import { DEFAULT_RUNTIME_MODE, type MessageId, } from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { buildThreadWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -269,7 +269,7 @@ export function useThreadOutboxDrain(): void { branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchName: buildThreadWorktreeBranchName(queuedMessage.threadId, randomHex), }), }); return completeDelivery(deliveryResult); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 2608ccc16ae..5f94c9c8ed4 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -42,6 +42,7 @@ export interface ServerDerivedPaths { readonly anonymousIdPath: string; readonly environmentIdPath: string; readonly serverRuntimeStatePath: string; + readonly worktreeRegistryPath: string; readonly secretsDir: string; } @@ -116,6 +117,7 @@ export const deriveServerPaths = Effect.fn(function* ( anonymousIdPath: join(stateDir, "anonymous-id"), environmentIdPath: join(stateDir, "environment-id"), serverRuntimeStatePath: join(stateDir, "server-runtime.json"), + worktreeRegistryPath: join(stateDir, "worktree-registry.json"), secretsDir: join(stateDir, "secrets"), }; }); diff --git a/apps/server/src/git/WorktreeBasePlanner.ts b/apps/server/src/git/WorktreeBasePlanner.ts new file mode 100644 index 00000000000..6d1c74fb23a --- /dev/null +++ b/apps/server/src/git/WorktreeBasePlanner.ts @@ -0,0 +1,200 @@ +import { + GitCommandError, + WORKTREE_BASE_FRESHNESS_WINDOW, + WORKTREE_BASE_USABLE_HORIZON, + type WorktreeBaseProvenance, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import * as GitWorkflowService from "./GitWorkflowService.ts"; + +export interface WorktreeBasePin { + /** Commit SHA the new worktree is created from. */ + readonly commitSha: string; + /** Remote-tracking ref the SHA was resolved from. */ + readonly remoteRefName: string; + readonly provenance: WorktreeBaseProvenance; + /** Time of the successful fetch backing this pin (null when unknown). */ + readonly fetchedAt: string | null; +} + +export interface EnsureFreshRemoteResult { + readonly fetchedAt: string | null; + readonly fetchSucceeded: boolean; +} + +/** + * Plans the base commit for new worktree threads. + * + * Freshness ladder ("always current" without a blocking fetch on every send): + * 1. A fetch of the repo's primary remote within {@link WORKTREE_BASE_FRESHNESS_WINDOW} + * counts as fresh — pin the remote-tracking commit immediately. + * 2. Otherwise fetch now (deduped per repository: concurrent sends and the + * composer-open prefetch share one in-flight fetch). + * 3. If that fetch fails but the last successful fetch is within + * {@link WORKTREE_BASE_USABLE_HORIZON}, proceed on the last-known remote + * commit with `provenance: "stale"` (fail-visible). + * 4. With no usable fetch, fail closed — the caller surfaces the error and + * never silently falls back to a local ref. + */ +export class WorktreeBasePlanner extends Context.Service< + WorktreeBasePlanner, + { + /** + * Fetch the repository's primary remote unless a successful fetch is + * already within the freshness window. Never fails: fetch failures are + * reported through `fetchSucceeded` so composer-open prefetches are + * fire-and-forget. + */ + readonly ensureFreshRemote: (input: { + readonly cwd: string; + }) => Effect.Effect; + + /** + * Resolve the pinned base commit for a new worktree from `baseRef`, + * applying the freshness ladder above. + */ + readonly pinRemoteBase: (input: { + readonly cwd: string; + readonly baseRef: string; + }) => Effect.Effect; + } +>()("t3/git/WorktreeBasePlanner") {} + +interface RepoFetchState { + /** Completion time of the last successful fetch (ISO). */ + lastSuccessAt: string | null; + /** Shared in-flight fetch so concurrent callers coalesce. */ + inFlight: Deferred.Deferred | null; +} + +export const make = Effect.gen(function* () { + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + + // Keyed by cwd. Bootstraps and the composer-open prefetch always pass the + // project root, so a per-cwd key deduplicates the flows that matter. + const fetchStateByCwd = new Map(); + + const getState = (cwd: string): RepoFetchState => { + const existing = fetchStateByCwd.get(cwd); + if (existing) { + return existing; + } + const created: RepoFetchState = { lastSuccessAt: null, inFlight: null }; + fetchStateByCwd.set(cwd, created); + return created; + }; + + const isWithin = (isoTime: string | null, window: Duration.Duration): Effect.Effect => + Effect.gen(function* () { + if (isoTime === null) { + return false; + } + const at = Date.parse(isoTime); + if (Number.isNaN(at)) { + return false; + } + const now = yield* Clock.currentTimeMillis; + return now - at <= Duration.toMillis(window); + }); + + const runDedupedFetch = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const state = getState(cwd); + if (state.inFlight) { + return yield* Deferred.await(state.inFlight); + } + const inFlight = yield* Deferred.make(); + state.inFlight = inFlight; + return yield* gitWorkflow.fetchRemote({ cwd, remoteName: "origin" }).pipe( + Effect.flatMap(() => + DateTime.now.pipe( + Effect.map((now) => { + state.lastSuccessAt = DateTime.formatIso(now); + return true; + }), + ), + ), + Effect.catch((error) => + Effect.logDebug("worktree base fetch failed", { + cwd, + detail: error.message, + }).pipe(Effect.map(() => false)), + ), + Effect.onExit((exit) => { + state.inFlight = null; + return Deferred.done(inFlight, Exit.isSuccess(exit) ? exit : Exit.succeed(false)).pipe( + Effect.asVoid, + ); + }), + ); + }); + + const ensureFreshRemote: WorktreeBasePlanner["Service"]["ensureFreshRemote"] = Effect.fn( + "WorktreeBasePlanner.ensureFreshRemote", + )(function* (input) { + const state = getState(input.cwd); + if (yield* isWithin(state.lastSuccessAt, WORKTREE_BASE_FRESHNESS_WINDOW)) { + return { fetchedAt: state.lastSuccessAt, fetchSucceeded: true }; + } + const fetchSucceeded = yield* runDedupedFetch(input.cwd); + return { fetchedAt: state.lastSuccessAt, fetchSucceeded }; + }); + + const pinRemoteBase: WorktreeBasePlanner["Service"]["pinRemoteBase"] = Effect.fn( + "WorktreeBasePlanner.pinRemoteBase", + )(function* (input) { + const { fetchedAt, fetchSucceeded } = yield* ensureFreshRemote({ cwd: input.cwd }); + + const resolvePin = (provenance: WorktreeBaseProvenance) => + gitWorkflow + .resolveRemoteTrackingCommit({ + cwd: input.cwd, + refName: input.baseRef, + fallbackRemoteName: "origin", + }) + .pipe( + Effect.map( + (resolved): WorktreeBasePin => ({ + commitSha: resolved.commitSha, + remoteRefName: resolved.remoteRefName, + provenance, + fetchedAt, + }), + ), + ); + + if (fetchSucceeded) { + return yield* resolvePin("fresh"); + } + + // Fail-visible: the remote is unreachable, but a recent fetch means the + // remote-tracking ref is still a sane base. Proceed with stale provenance. + if (yield* isWithin(fetchedAt, WORKTREE_BASE_USABLE_HORIZON)) { + return yield* resolvePin("stale"); + } + + // Fail closed: no usable remote data. The caller must not silently fall + // back to a local ref — surfacing this error is the contract. + return yield* new GitCommandError({ + operation: "WorktreeBasePlanner.pinRemoteBase", + command: "fetch", + cwd: input.cwd, + detail: `Couldn't prepare a fresh worktree from ${input.baseRef}: the remote could not be reached and no recent fetch is available. Nothing has started and your checkout was not modified.`, + }); + }); + + return WorktreeBasePlanner.of({ + ensureFreshRemote, + pinRemoteBase, + }); +}); + +export const layer = Layer.effect(WorktreeBasePlanner, make); diff --git a/apps/server/src/git/WorktreeRegistry.ts b/apps/server/src/git/WorktreeRegistry.ts new file mode 100644 index 00000000000..4f567944018 --- /dev/null +++ b/apps/server/src/git/WorktreeRegistry.ts @@ -0,0 +1,148 @@ +import { ThreadId, WorktreeBaseProvenance } from "@t3tools/contracts"; +import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ServerConfig from "../config.ts"; + +export const WorktreeRegistryStatus = Schema.Literals(["active", "thread-deleted"]); +export type WorktreeRegistryStatus = typeof WorktreeRegistryStatus.Type; + +/** + * One record per worktree this server created for a thread. The registry is + * the durable worktree ↔ thread association: attribution never depends on + * scanning paths or parsing branch names, which makes lifecycle/GC tooling + * cheap to build later. + */ +export const WorktreeRegistryRecord = Schema.Struct({ + threadId: ThreadId, + worktreePath: Schema.String, + branch: Schema.String, + projectCwd: Schema.String, + baseRefName: Schema.NullOr(Schema.String), + baseCommitSha: Schema.NullOr(Schema.String), + baseProvenance: Schema.NullOr(WorktreeBaseProvenance), + createdAt: Schema.String, + status: WorktreeRegistryStatus, +}); +export type WorktreeRegistryRecord = typeof WorktreeRegistryRecord.Type; + +const WorktreeRegistryFile = Schema.Struct({ + version: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(1))), + worktrees: Schema.Array(WorktreeRegistryRecord).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), +}); +type WorktreeRegistryFile = typeof WorktreeRegistryFile.Type; + +const decodeRegistryExit = Schema.decodeUnknownExit(fromLenientJson(WorktreeRegistryFile)); +const encodeRegistryJson = Schema.encodeEffect(fromJsonStringPretty(WorktreeRegistryFile)); + +const EMPTY_REGISTRY: WorktreeRegistryFile = { version: 1, worktrees: [] }; + +export class WorktreeRegistry extends Context.Service< + WorktreeRegistry, + { + /** Record (or refresh) the worktree created for a thread. Never fails. */ + readonly register: (record: Omit) => Effect.Effect; + /** Mark all records for a thread as belonging to a deleted thread. Never fails. */ + readonly markThreadDeleted: (threadId: ThreadId) => Effect.Effect; + /** Remove records whose worktree path was removed from disk. Never fails. */ + readonly unregisterPath: (worktreePath: string) => Effect.Effect; + readonly list: Effect.Effect>; + } +>()("t3/git/WorktreeRegistry") {} + +export const make = Effect.gen(function* () { + const { worktreeRegistryPath } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const writeSemaphore = yield* Semaphore.make(1); + const loadedRef = yield* Ref.make(null); + + const loadFromDisk = Effect.gen(function* () { + const exists = yield* fs.exists(worktreeRegistryPath); + if (!exists) { + return EMPTY_REGISTRY; + } + const raw = yield* fs.readFileString(worktreeRegistryPath); + const decoded = decodeRegistryExit(raw); + if (decoded._tag === "Failure") { + yield* Effect.logWarning("failed to parse worktree registry, starting fresh", { + path: worktreeRegistryPath, + }); + return EMPTY_REGISTRY; + } + return decoded.value; + }); + + const getRegistry = Effect.gen(function* () { + const loaded = yield* Ref.get(loadedRef); + if (loaded !== null) { + return loaded; + } + const fromDisk = yield* loadFromDisk; + yield* Ref.set(loadedRef, fromDisk); + return fromDisk; + }); + + const mutate = ( + update: (current: WorktreeRegistryFile) => WorktreeRegistryFile, + ): Effect.Effect => + Semaphore.withPermit(writeSemaphore)( + Effect.gen(function* () { + const current = yield* getRegistry; + const next = update(current); + yield* Ref.set(loadedRef, next); + const contents = yield* encodeRegistryJson(next); + yield* writeFileStringAtomically({ + filePath: worktreeRegistryPath, + contents, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + }), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to persist worktree registry", { cause }), + ), + Effect.asVoid, + ); + + return WorktreeRegistry.of({ + register: (record) => + mutate((current) => ({ + ...current, + worktrees: [ + ...current.worktrees.filter((entry) => entry.worktreePath !== record.worktreePath), + { ...record, status: "active" }, + ], + })), + markThreadDeleted: (threadId) => + mutate((current) => ({ + ...current, + worktrees: current.worktrees.map((entry) => + entry.threadId === threadId ? { ...entry, status: "thread-deleted" as const } : entry, + ), + })), + unregisterPath: (worktreePath) => + mutate((current) => ({ + ...current, + worktrees: current.worktrees.filter((entry) => entry.worktreePath !== worktreePath), + })), + list: getRegistry.pipe( + Effect.map((registry) => registry.worktrees), + Effect.orElseSucceed(() => []), + ), + }); +}); + +export const layer = Layer.effect(WorktreeRegistry, make); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 7d8a24069a3..4ca820c68d2 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as WorktreeRegistry from "../../git/WorktreeRegistry.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -40,6 +41,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const terminalManager = yield* TerminalManager.TerminalManager; + const worktreeRegistry = yield* WorktreeRegistry.WorktreeRegistry; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ @@ -61,6 +63,8 @@ const make = Effect.gen(function* () { const { threadId } = event.payload; yield* stopProviderSession(threadId); yield* closeThreadTerminals(threadId); + // Keep the record for future GC tooling; just mark it unowned. + yield* worktreeRegistry.markThreadDeleted(threadId); }); const processThreadDeletedSafely = (event: ThreadDeletedEvent) => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..104bdb6901b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -103,6 +103,8 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as WorktreeBasePlanner from "./git/WorktreeBasePlanner.ts"; +import * as WorktreeRegistry from "./git/WorktreeRegistry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -503,6 +505,10 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), ); + const worktreeBasePlannerLayer = WorktreeBasePlanner.layer.pipe( + Layer.provide(gitWorkflowLayer), + ); + const worktreeRegistryLayer = WorktreeRegistry.layer; const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), ); @@ -628,7 +634,9 @@ const buildAppUnderTest = (options?: { ), Layer.provide(gitManagerLayer), Layer.provide(gitVcsDriverLayer), - Layer.provide(gitWorkflowLayer), + Layer.provide( + Layer.mergeAll(gitWorkflowLayer, worktreeBasePlannerLayer, worktreeRegistryLayer), + ), Layer.provide(reviewLayer), Layer.provide(vcsProvisioningLayer), Layer.provide( @@ -6224,7 +6232,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 5); + assert.equal(response.sequence, 6); assert.deepEqual( dispatchedCommands.map((command) => command.type), [ @@ -6232,6 +6240,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "thread.meta.update", "thread.activity.append", "thread.activity.append", + "thread.activity.append", "thread.turn.start", ], ); @@ -6241,6 +6250,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { newRefName: "t3code/bootstrap-refName", baseRefName: "main", path: null, + uniquifyNewRefName: true, }); assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { cwd: "/tmp/project", @@ -6270,9 +6280,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual( setupActivities.map((command) => command.activity.kind), - ["setup-script.requested", "setup-script.started"], + ["worktree.base-pinned", "setup-script.requested", "setup-script.started"], ); - const finalCommand = dispatchedCommands[4]; + const basePinnedActivity = setupActivities[0]; + assert.deepEqual(basePinnedActivity?.activity.payload, { + branch: "t3code/bootstrap-refName", + worktreePath: "/tmp/bootstrap-worktree", + baseRefName: "origin/main", + baseCommitSha: fetchedOriginCommit, + baseProvenance: "fresh", + baseFetchedAt: basePinnedActivity + ? (basePinnedActivity.activity.payload as { baseFetchedAt: string | null }) + .baseFetchedAt + : null, + }); + const finalCommand = dispatchedCommands[5]; assertTrue(finalCommand?.type === "thread.turn.start"); if (finalCommand?.type === "thread.turn.start") { assert.equal(finalCommand.bootstrap, undefined); @@ -6367,14 +6389,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 4); + assert.equal(response.sequence, 5); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.meta.update", + "thread.activity.append", + "thread.activity.append", + "thread.turn.start", + ], ); const setupFailureActivity = dispatchedCommands.find( (command): command is Extract => - command.type === "thread.activity.append", + command.type === "thread.activity.append" && + command.activity.kind.startsWith("setup-script."), ); assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); assert.deepEqual(setupFailureActivity?.activity.payload, { @@ -6488,14 +6517,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 4); + assert.equal(response.sequence, 5); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.meta.update", + "thread.activity.append", + "thread.activity.append", + "thread.turn.start", + ], ); const setupActivities = dispatchedCommands.filter( (command): command is Extract => - command.type === "thread.activity.append", + command.type === "thread.activity.append" && + command.activity.kind.startsWith("setup-script."), ); assert.deepEqual( setupActivities.map((command) => command.activity.kind), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c632d8486c..63d4b1d8de6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -65,6 +65,8 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as WorktreeBasePlanner from "./git/WorktreeBasePlanner.ts"; +import * as WorktreeRegistry from "./git/WorktreeRegistry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -161,7 +163,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), - Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ThreadDeletionReactorLive.pipe(Layer.provide(WorktreeRegistry.layer))), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -212,6 +214,10 @@ const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( Layer.provideMerge(GitLayerLive), ); +const WorktreeBasePlannerLayerLive = WorktreeBasePlanner.layer.pipe( + Layer.provide(GitWorkflowLayerLive), +); + const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), @@ -227,6 +233,8 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(VcsDriverRegistryLayerLive), Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(WorktreeBasePlannerLayerLive), + Layer.provideMerge(WorktreeRegistry.layer), Layer.provideMerge(ReviewLayerLive), Layer.provideMerge(SourceControlRepositoryServiceLayerLive), Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a406cbce549..e4c708a6c6f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2242,22 +2242,48 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const resolveAvailableWorktreeBranchName = Effect.fn("resolveAvailableWorktreeBranchName")( + function* (cwd: string, desiredBranch: string) { + if (!(yield* branchExists(cwd, desiredBranch))) { + return desiredBranch; + } + for (let suffix = 2; suffix <= 100; suffix += 1) { + const candidate = `${desiredBranch}-${suffix}`; + if (!(yield* branchExists(cwd, candidate))) { + return candidate; + } + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.createWorktree", + cwd, + args: ["worktree", "add", "-b", desiredBranch], + }), + detail: `Could not find an available worktree branch name for '${desiredBranch}'.`, + }); + }, + ); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { - const targetBranch = input.newRefName ?? input.refName; + const newRefName = + input.newRefName !== undefined && input.uniquifyNewRefName + ? yield* resolveAvailableWorktreeBranchName(input.cwd, input.newRefName) + : input.newRefName; + const targetBranch = newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + const args = newRefName + ? ["worktree", "add", "-b", newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { fallbackErrorDetail: "git worktree add failed", }); - if (input.newRefName && input.baseRefName) { + if (newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( input.baseRefName, @@ -2266,7 +2292,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ "config", - `branch.${input.newRefName}.gh-merge-base`, + `branch.${newRefName}.gh-merge-base`, baseBranch, ]); } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..377833da63a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -91,6 +91,8 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as WorktreeBasePlanner from "./git/WorktreeBasePlanner.ts"; +import * as WorktreeRegistry from "./git/WorktreeRegistry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -310,6 +312,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], + [WS_METHODS.vcsPrefetchRemote, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], @@ -401,6 +404,8 @@ const makeWsRpcLayer = ( const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const worktreeBasePlanner = yield* WorktreeBasePlanner.WorktreeBasePlanner; + const worktreeRegistry = yield* WorktreeRegistry.WorktreeRegistry; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -527,7 +532,11 @@ const makeWsRpcLayer = ( const appendSetupScriptActivity = (input: { readonly threadId: ThreadId; - readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly kind: + | "setup-script.requested" + | "setup-script.started" + | "setup-script.failed" + | "worktree.base-pinned"; readonly summary: string; readonly createdAt: string; readonly payload: Record; @@ -556,6 +565,53 @@ const makeWsRpcLayer = ( ), ); + /** + * Record where a new worktree thread's base came from as a thread + * activity — the durable, user-visible provenance ("pinned at ", + * and a warning when the remote was unreachable and the last-known + * commit was used instead). Never fails the bootstrap. + */ + const appendWorktreeBaseActivity = (input: { + readonly threadId: ThreadId; + readonly branch: string; + readonly worktreePath: string; + readonly baseRefName: string; + readonly basePin: WorktreeBasePlanner.WorktreeBasePin | null; + }) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + const shortSha = input.basePin?.commitSha.slice(0, 7); + const summary = + input.basePin === null + ? `Created worktree from local ${input.baseRefName}` + : input.basePin.provenance === "stale" + ? `Remote unreachable — created worktree from last-known ${input.baseRefName} (${shortSha})` + : `Created worktree from ${input.baseRefName} (${shortSha})`; + yield* appendSetupScriptActivity({ + threadId: input.threadId, + kind: "worktree.base-pinned", + summary, + createdAt, + payload: { + branch: input.branch, + worktreePath: input.worktreePath, + baseRefName: input.baseRefName, + baseCommitSha: input.basePin?.commitSha ?? null, + baseProvenance: input.basePin?.provenance ?? "local", + baseFetchedAt: input.basePin?.fetchedAt ?? null, + }, + tone: input.basePin?.provenance === "stale" ? "error" : "info", + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to record worktree base provenance activity", { + threadId: input.threadId, + cause, + }), + ), + Effect.asVoid, + ); + const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { const error = Cause.squash(cause); return isOrchestrationDispatchCommandError(error) @@ -836,25 +892,28 @@ const makeWsRpcLayer = ( } if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { - yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, - remoteName: "origin", + const prepareWorktree = bootstrap.prepareWorktree; + let worktreeBaseRef = prepareWorktree.baseBranch; + let basePin: WorktreeBasePlanner.WorktreeBasePin | null = null; + if (prepareWorktree.startFromOrigin) { + // Pin the base to a remote commit via the freshness ladder: + // fresh fetch → recent fetch (stale, fail-visible) → fail + // closed. Never silently falls back to the local ref. + basePin = yield* worktreeBasePlanner.pinRemoteBase({ + cwd: prepareWorktree.projectCwd, + baseRef: prepareWorktree.baseBranch, }); - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, - fallbackRemoteName: "origin", - }); - worktreeBaseRef = resolvedRemoteBase.commitSha; + worktreeBaseRef = basePin.commitSha; } const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, path: null, + // Branch names derive from the thread id, so a resend after a + // partial failure may find the branch already exists. + uniquifyNewRefName: true, }); targetWorktreePath = worktree.worktree.path; yield* orchestrationEngine.dispatch({ @@ -864,6 +923,24 @@ const makeWsRpcLayer = ( branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); + yield* worktreeRegistry.register({ + threadId: command.threadId, + worktreePath: targetWorktreePath, + branch: worktree.worktree.refName, + projectCwd: prepareWorktree.projectCwd, + baseRefName: basePin?.remoteRefName ?? prepareWorktree.baseBranch, + baseCommitSha: basePin?.commitSha ?? null, + baseProvenance: + basePin?.provenance ?? (prepareWorktree.startFromOrigin ? null : "local"), + createdAt: yield* nowIso, + }); + yield* appendWorktreeBaseActivity({ + threadId: command.threadId, + branch: worktree.worktree.refName, + worktreePath: targetWorktreePath, + baseRefName: basePin?.remoteRefName ?? prepareWorktree.baseBranch, + basePin, + }); yield* refreshGitStatus(targetWorktreePath); } @@ -1537,6 +1614,12 @@ const makeWsRpcLayer = ( "rpc.aggregate": "vcs", }, ), + [WS_METHODS.vcsPrefetchRemote]: (input) => + observeRpcEffect( + WS_METHODS.vcsPrefetchRemote, + worktreeBasePlanner.ensureFreshRemote({ cwd: input.cwd }), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsPull]: (input) => observeRpcEffect( WS_METHODS.vcsPull, diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a961..234f2eb3cd7 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches, + derivePinnedWorktreeBase, deriveLocalBranchNameFromRemoteRef, resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, @@ -11,6 +12,7 @@ import { resolveEnvModeLabel, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, + resolvePinnedBaseTitle, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -166,6 +168,75 @@ describe("resolveLockedWorkspaceLabel", () => { it("uses a shorter label for an attached worktree", () => { expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree"); }); + + it("turns the label into the pinned fact once the base is known", () => { + expect( + resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a", { + baseRefName: "origin/main", + baseCommitSha: "0123456789abcdef", + baseProvenance: "fresh", + }), + ).toBe("Worktree · 0123456"); + }); +}); + +describe("derivePinnedWorktreeBase", () => { + it("reads the latest worktree.base-pinned activity payload", () => { + expect( + derivePinnedWorktreeBase([ + { kind: "setup-script.started", payload: {} }, + { + kind: "worktree.base-pinned", + payload: { + baseRefName: "origin/main", + baseCommitSha: "0123456789abcdef", + baseProvenance: "stale", + }, + }, + ]), + ).toEqual({ + baseRefName: "origin/main", + baseCommitSha: "0123456789abcdef", + baseProvenance: "stale", + }); + }); + + it("returns null when no pin activity exists", () => { + expect(derivePinnedWorktreeBase([{ kind: "tool.started", payload: {} }])).toBeNull(); + }); +}); + +describe("resolvePinnedBaseTitle", () => { + it("describes fresh pins", () => { + expect( + resolvePinnedBaseTitle({ + baseRefName: "origin/main", + baseCommitSha: "0123456789abcdef", + baseProvenance: "fresh", + }), + ).toBe("Pinned at 0123456 from latest origin/main"); + }); + + it("discloses stale pins", () => { + expect( + resolvePinnedBaseTitle({ + baseRefName: "origin/main", + baseCommitSha: "0123456789abcdef", + baseProvenance: "stale", + }), + ).toContain("remote was unreachable"); + }); + + it("returns null without a pinned commit", () => { + expect( + resolvePinnedBaseTitle({ + baseRefName: "origin/main", + baseCommitSha: null, + baseProvenance: "local", + }), + ).toBeNull(); + expect(resolvePinnedBaseTitle(null)).toBeNull(); + }); }); describe("deriveLocalBranchNameFromRemoteRef", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..bd2e8a2178e 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -50,8 +50,66 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } -export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Worktree" : "Local checkout"; +/** + * Provenance of a worktree thread's pinned base, derived from the + * `worktree.base-pinned` activity the server records during bootstrap. + */ +export interface WorktreePinnedBase { + readonly baseRefName: string; + readonly baseCommitSha: string | null; + readonly baseProvenance: "fresh" | "stale" | "local"; +} + +export function derivePinnedWorktreeBase( + activities: ReadonlyArray<{ kind: string; payload: unknown }>, +): WorktreePinnedBase | null { + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (activity?.kind !== "worktree.base-pinned") continue; + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + if (!payload) return null; + const baseRefName = typeof payload.baseRefName === "string" ? payload.baseRefName : null; + if (!baseRefName) return null; + const baseCommitSha = typeof payload.baseCommitSha === "string" ? payload.baseCommitSha : null; + const baseProvenance = + payload.baseProvenance === "fresh" || + payload.baseProvenance === "stale" || + payload.baseProvenance === "local" + ? payload.baseProvenance + : "local"; + return { baseRefName, baseCommitSha, baseProvenance }; + } + return null; +} + +/** + * Locked (post-send) workspace label. Once the base pin is known the promise + * becomes a fact: "Worktree · a1b2c3f" instead of just "Worktree". + */ +export function resolveLockedWorkspaceLabel( + activeWorktreePath: string | null, + pinnedBase?: WorktreePinnedBase | null, +): string { + if (!activeWorktreePath) { + return "Local checkout"; + } + const shortSha = pinnedBase?.baseCommitSha?.slice(0, 7); + return shortSha ? `Worktree · ${shortSha}` : "Worktree"; +} + +export function resolvePinnedBaseTitle(pinnedBase: WorktreePinnedBase | null): string | null { + if (!pinnedBase?.baseCommitSha) { + return null; + } + const shortSha = pinnedBase.baseCommitSha.slice(0, 7); + return pinnedBase.baseProvenance === "stale" + ? `Pinned at ${shortSha} from ${pinnedBase.baseRefName} (remote was unreachable — last-known commit)` + : pinnedBase.baseProvenance === "fresh" + ? `Pinned at ${shortSha} from latest ${pinnedBase.baseRefName}` + : `Pinned at ${shortSha} from local ${pinnedBase.baseRefName}`; } export function resolveEffectiveEnvMode(input: { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 958cb3743c7..57f32feb5e4 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -16,6 +16,7 @@ import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, type EnvironmentOption, + derivePinnedWorktreeBase, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveEffectiveEnvMode, @@ -48,6 +49,12 @@ interface BranchToolbarProps { startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; envLocked: boolean; + /** The project checkout has uncommitted changes (pre-send disclosure). */ + workingTreeDirty?: boolean; + /** Mode this project's new threads currently default to. */ + projectDefaultEnvMode?: EnvMode | null; + /** Persist the current mode as this project's default (explicit write). */ + onSetProjectDefaultEnvMode?: (mode: EnvMode) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; @@ -201,6 +208,9 @@ export const BranchToolbar = memo(function BranchToolbar({ startFromOrigin, onStartFromOriginChange, envLocked, + workingTreeDirty, + projectDefaultEnvMode, + onSetProjectDefaultEnvMode, onCheckoutPullRequestRequest, onComposerFocusRequest, availableEnvironments, @@ -222,6 +232,10 @@ export const BranchToolbar = memo(function BranchToolbar({ const activeProject = useProject(activeProjectRef); const hasActiveThread = serverThread !== null || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const pinnedBase = useMemo( + () => (serverThread ? derivePinnedWorktreeBase(serverThread.activities) : null), + [serverThread], + ); const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ @@ -238,56 +252,92 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; + // Dirty-state disclosure runs both directions and only while the choice is + // still editable: in checkout mode the agent's edits will mix with the + // user's uncommitted work; in worktree mode those uncommitted changes won't + // be included. Each hint offers the one-click switch to the other mode. + const dirtyDisclosure = + !envModeLocked && workingTreeDirty && serverThread === null + ? effectiveEnvMode === "local" + ? { + text: "This checkout has uncommitted changes — the agent will work alongside them.", + action: "Use a fresh worktree", + nextMode: "worktree" as const, + } + : { + text: "Uncommitted changes in this checkout won't be included in the fresh worktree.", + action: "Use current checkout", + nextMode: "local" as const, + } + : null; + return ( -
- {isMobile ? ( - - ) : ( -
- {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( - <> - - - - )} - + {dirtyDisclosure ? ( +
+ {dirtyDisclosure.text} + +
+ ) : null} +
+ {isMobile ? ( + -
- )} + ) : ( +
+ {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( + <> + + + + )} + +
+ )} - + +
); }); diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..eceac167419 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,11 +1,13 @@ -import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; +import { FolderGit2Icon, FolderGitIcon, FolderIcon, PinIcon } from "lucide-react"; import { memo, useMemo } from "react"; import { resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, + resolvePinnedBaseTitle, type EnvMode, + type WorktreePinnedBase, } from "./BranchToolbar.logic"; import { Select, @@ -17,18 +19,36 @@ import { SelectValue, } from "./ui/select"; +/** + * Sentinel select value for the "Always for this project" action. Selecting + * it never becomes the control's value — it's intercepted in onValueChange + * and routed to an explicit settings write instead. + */ +const SET_PROJECT_DEFAULT_VALUE = "__set_project_default__"; + interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; + pinnedBase?: WorktreePinnedBase | null; + /** + * Mode the project's new threads default to today (after overrides). Used + * to decide whether the "Always for this project" action is meaningful. + */ + projectDefaultEnvMode?: EnvMode | null; onEnvModeChange: (mode: EnvMode) => void; + /** Persist the current mode as this project's default (explicit write). */ + onSetProjectDefaultEnvMode?: (mode: EnvMode) => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ envLocked, effectiveEnvMode, activeWorktreePath, + pinnedBase, + projectDefaultEnvMode, onEnvModeChange, + onSetProjectDefaultEnvMode, }: BranchToolbarEnvModeSelectorProps) { const envModeItems = useMemo( () => [ @@ -37,19 +57,29 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ], [activeWorktreePath], ); + const showSetProjectDefault = + onSetProjectDefaultEnvMode !== undefined && + projectDefaultEnvMode != null && + effectiveEnvMode !== projectDefaultEnvMode; if (envLocked) { + // Post-send the label states the outcome, not the intent: the pinned + // base commit (and, on hover, its provenance) replaces "New worktree". + const lockedTitle = resolvePinnedBaseTitle(pinnedBase ?? null); return ( - + {activeWorktreePath ? ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath, pinnedBase)} ) : ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath, pinnedBase)} )} @@ -60,7 +90,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..8c0435770b5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { } from "@t3tools/client-runtime/connection"; import { parseScopedThreadKey, + scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, @@ -112,7 +113,7 @@ import { import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteContext"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { buildThreadWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { @@ -150,7 +151,13 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { useEnvironmentSettings } from "../hooks/useSettings"; +import { + useClientSettings, + useEnvironmentSettings, + useUpdateClientSettings, +} from "../hooks/useSettings"; +import { useEnvironmentPresentation } from "../state/presentation"; +import { resolveSurfaceThreadEnvMode } from "../lib/threadSurface"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -2120,6 +2127,43 @@ function ChatViewContent(props: ChatViewProps) { input: { cwd: gitCwd }, }), ); + const prefetchRemote = useAtomCommand(vcsEnvironment.prefetchRemote, { reportFailure: false }); + // Prefetch the remote the moment a worktree-mode draft composer is open, so + // the send-time freshness check almost always hits the fresh window and the + // user never waits on a blocking fetch. Fire-and-forget; the server dedupes + // concurrent fetches per repository. + const prefetchedDraftCwdRef = useRef(null); + const draftEnvModeForPrefetch = isLocalDraftThread ? draftThread?.envMode : undefined; + const draftStartFromOriginForPrefetch = isLocalDraftThread + ? draftThread?.startFromOrigin + : undefined; + useEffect(() => { + const projectCwd = activeProject?.workspaceRoot ?? null; + if ( + !isLocalDraftThread || + draftEnvModeForPrefetch !== "worktree" || + draftStartFromOriginForPrefetch !== true || + projectCwd === null + ) { + return; + } + const prefetchKey = `${environmentId}:${projectCwd}`; + if (prefetchedDraftCwdRef.current === prefetchKey) { + return; + } + prefetchedDraftCwdRef.current = prefetchKey; + void prefetchRemote({ + environmentId, + input: { cwd: projectCwd }, + }); + }, [ + activeProject?.workspaceRoot, + draftEnvModeForPrefetch, + draftStartFromOriginForPrefetch, + environmentId, + isLocalDraftThread, + prefetchRemote, + ]); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // Prefer an instance-id match so a custom Codex instance (e.g. @@ -4154,7 +4198,7 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), + branch: buildThreadWorktreeBranchName(threadIdForSend, randomHex), ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, @@ -4867,6 +4911,34 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const projectThreadEnvModeOverrides = useClientSettings( + (clientSettings) => clientSettings.projectThreadEnvModeOverrides, + ); + const updateClientSettings = useUpdateClientSettings(); + const { presentation: activeEnvironmentPresentation } = useEnvironmentPresentation(environmentId); + const envModeOverrideProjectKey = activeProjectRef ? scopedProjectKey(activeProjectRef) : null; + const projectDefaultEnvMode: DraftThreadEnvMode | null = envModeOverrideProjectKey + ? (projectThreadEnvModeOverrides[envModeOverrideProjectKey] ?? + resolveSurfaceThreadEnvMode({ + settings, + target: activeEnvironmentPresentation?.entry.target ?? null, + })) + : null; + // "Always for this project": the only sanctioned way a composer choice + // becomes a default, and it goes through an explicit settings write. + const onSetProjectDefaultEnvMode = useCallback( + (mode: DraftThreadEnvMode) => { + if (!envModeOverrideProjectKey) return; + updateClientSettings({ + projectThreadEnvModeOverrides: { + ...projectThreadEnvModeOverrides, + [envModeOverrideProjectKey]: mode, + }, + }); + }, + [envModeOverrideProjectKey, projectThreadEnvModeOverrides, updateClientSettings], + ); + const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { if (canOverrideServerThreadEnvMode && activeThread) { setPendingServerThreadStartFromOriginByThreadId((current) => @@ -5232,6 +5304,9 @@ function ChatViewContent(props: ChatViewProps) { onEnvModeChange={onEnvModeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + workingTreeDirty={Boolean(gitStatusQuery.data?.hasWorkingTreeChanges)} + projectDefaultEnvMode={projectDefaultEnvMode} + onSetProjectDefaultEnvMode={onSetProjectDefaultEnvMode} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..3455b647ad5 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -261,90 +261,22 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); describe("resolveSidebarNewThreadSeedContext", () => { - it("prefers the default worktree mode over active thread context", () => { + it("always starts from the resolved default mode — active thread and draft context never leak in", () => { expect( resolveSidebarNewThreadSeedContext({ - projectId: "project-1", defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "feature/existing", - worktreePath: "/repo/.t3/worktrees/existing", - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/draft", - worktreePath: "/repo/.t3/worktrees/draft", - envMode: "worktree", - startFromOrigin: true, - }, }), ).toEqual({ envMode: "worktree", }); - }); - - it("inherits the active server thread context when creating a new thread in the same project", () => { expect( resolveSidebarNewThreadSeedContext({ - projectId: "project-1", defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, }), ).toEqual({ - branch: "effect-atom", - worktreePath: null, envMode: "local", }); }); - - it("prefers the active draft thread context when it matches the target project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - startFromOrigin: true, - }, - }), - ).toEqual({ - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - startFromOrigin: true, - }); - }); - - it("falls back to the default env mode when there is no matching active thread context", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-2", - defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - envMode: "worktree", - }); - }); }); describe("orderItemsByPreferredIds", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..96869fc548e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -184,50 +184,17 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +/** + * New threads always start from the resolved default workspace mode. + * Mode/branch/worktree choices on the active thread or draft are draft-only + * exceptions and are deliberately NOT inherited — a one-off "use current + * checkout" must never retrain what the next new thread does. + */ export function resolveSidebarNewThreadSeedContext(input: { - projectId: string; defaultEnvMode: SidebarNewThreadEnvMode; - activeThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - } | null; - activeDraftThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - envMode: SidebarNewThreadEnvMode; - startFromOrigin: boolean; - } | null; }): { - branch?: string | null; - worktreePath?: string | null; envMode: SidebarNewThreadEnvMode; - startFromOrigin?: boolean; } { - if (input.defaultEnvMode === "worktree") { - return { - envMode: "worktree", - }; - } - - if (input.activeDraftThread?.projectId === input.projectId) { - return { - branch: input.activeDraftThread.branch, - worktreePath: input.activeDraftThread.worktreePath, - envMode: input.activeDraftThread.envMode, - startFromOrigin: input.activeDraftThread.startFromOrigin, - }; - } - - if (input.activeThread?.projectId === input.projectId) { - return { - branch: input.activeThread.branch, - worktreePath: input.activeThread.worktreePath, - envMode: input.activeThread.worktreePath ? "worktree" : "local", - }; - } - return { envMode: input.defaultEnvMode, }; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..7904827b2d4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -43,7 +43,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, - DEFAULT_SERVER_SETTINGS, ProjectId, type ScopedThreadRef, type ResolvedKeybindingsConfig, @@ -79,10 +78,8 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { - readThreadShell, useProject, useProjects, - useServerConfigs, useThreadShells, useThreadShellsForProjectRefs, } from "../state/entities"; @@ -118,11 +115,7 @@ import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { - buildThreadRouteParams, - resolveThreadRouteRef, - resolveThreadRouteTarget, -} from "../threadRoutes"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -189,8 +182,6 @@ import { isContextMenuPointerDown, isTrailingDoubleClick, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, - resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, @@ -1109,7 +1100,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec (settings) => settings.confirmThreadArchive, ); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const serverConfigs = useServerConfigs(); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); @@ -1836,61 +1826,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const createThreadForProjectMember = useCallback( (member: SidebarProjectGroupMember) => { - const currentRouteParams = - router.state.matches[router.state.matches.length - 1]?.params ?? {}; - const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); - const currentActiveThread = - currentRouteTarget?.kind === "server" - ? readThreadShell(currentRouteTarget.threadRef) - : null; - const draftStore = useComposerDraftStore.getState(); - const currentActiveDraftThread = - currentRouteTarget?.kind === "server" - ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null) - : currentRouteTarget?.kind === "draft" - ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) - : null; - const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: member.id, - defaultEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: - serverConfigs.get(member.environmentId)?.settings.defaultThreadEnvMode ?? - DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, - }), - activeThread: - currentActiveThread && currentActiveThread.projectId === member.id - ? { - projectId: currentActiveThread.projectId, - branch: currentActiveThread.branch, - worktreePath: currentActiveThread.worktreePath, - } - : null, - activeDraftThread: - currentActiveDraftThread && currentActiveDraftThread.projectId === member.id - ? { - projectId: currentActiveDraftThread.projectId, - branch: currentActiveDraftThread.branch, - worktreePath: currentActiveDraftThread.worktreePath, - envMode: currentActiveDraftThread.envMode, - startFromOrigin: currentActiveDraftThread.startFromOrigin, - } - : null, - }); if (isMobile) { setOpenMobile(false); } void (async () => { + // No seed context: new threads always start from the resolved + // default workspace mode (useNewThreadHandler derives it from the + // surface). The active thread's mode/branch is a draft-only + // exception and must not leak into the next thread. const result = await settlePromise(() => - handleNewThread(scopeProjectRef(member.environmentId, member.id), { - ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), - ...(seedContext.worktreePath !== undefined - ? { worktreePath: seedContext.worktreePath } - : {}), - envMode: seedContext.envMode, - ...(seedContext.startFromOrigin !== undefined - ? { startFromOrigin: seedContext.startFromOrigin } - : {}), - }), + handleNewThread(scopeProjectRef(member.environmentId, member.id)), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); @@ -1904,7 +1849,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } })(); }, - [handleNewThread, isMobile, router, serverConfigs, setOpenMobile], + [handleNewThread, isMobile, setOpenMobile], ); const handleCreateThreadClick = useCallback( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d56314..cf409268fd6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -405,7 +405,9 @@ export function useSettingsRestore(onRestored?: () => void) { Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) ? ["Automatic Git fetch interval"] : []), - ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode + ...(settings.deriveThreadEnvModeFromSurface !== + DEFAULT_UNIFIED_SETTINGS.deriveThreadEnvModeFromSurface || + settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), ...(settings.newWorktreesStartFromOrigin !== @@ -430,6 +432,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.deriveThreadEnvModeFromSurface, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.automaticGitFetchInterval, @@ -460,6 +463,7 @@ export function useSettingsRestore(onRestored?: () => void) { autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + deriveThreadEnvModeFromSurface: DEFAULT_UNIFIED_SETTINGS.deriveThreadEnvModeFromSurface, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, @@ -723,8 +727,10 @@ export function GeneralSettingsPanel() { updateSettings({ + deriveThreadEnvModeFromSurface: + DEFAULT_UNIFIED_SETTINGS.deriveThreadEnvModeFromSurface, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, @@ -742,19 +750,35 @@ export function GeneralSettingsPanel() { } control={