From c932f01556758ccab0576499979d967d6c56396e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 18 Jun 2026 20:18:17 -0700 Subject: [PATCH 1/2] Add origin fetch option for new worktrees - Propagate fetch-origin through draft, settings, and bootstrap worktree flows - Fetch remote before creating worktree when enabled --- apps/server/src/git/GitWorkflowService.ts | 20 ++++++ apps/server/src/server.test.ts | 50 +++++++++++++-- apps/server/src/vcs/GitVcsDriver.ts | 20 ++++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 63 +++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 34 ++++++++++ apps/server/src/ws.ts | 15 ++++- apps/web/src/components/BranchToolbar.tsx | 6 ++ .../BranchToolbarBranchSelector.tsx | 38 ++++++++++- apps/web/src/components/ChatView.tsx | 27 ++++++++ .../components/settings/SettingsPanels.tsx | 42 ++++++++++++- apps/web/src/composerDraftStore.test.ts | 15 +++++ apps/web/src/composerDraftStore.ts | 27 ++++++++ apps/web/src/hooks/useHandleNewThread.ts | 27 ++++++-- apps/web/src/lib/chatThreadActions.test.ts | 16 +++++ apps/web/src/lib/chatThreadActions.ts | 7 +++ packages/contracts/src/orchestration.test.ts | 2 + packages/contracts/src/orchestration.ts | 1 + packages/contracts/src/settings.test.ts | 12 ++++ packages/contracts/src/settings.ts | 4 ++ 19 files changed, 413 insertions(+), 13 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 5fce28922fd..0af4847f4ac 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -61,6 +61,18 @@ export interface GitWorkflowServiceShape { readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; + readonly fetchRemote: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; + readonly resolveRemoteTrackingCommit: (input: { + readonly cwd: string; + readonly refName: string; + readonly fallbackRemoteName: string; + }) => Effect.Effect< + { readonly commitSha: string; readonly remoteRefName: string }, + GitCommandError + >; readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, @@ -295,6 +307,14 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), ), + fetchRemote: (input) => + ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( + Effect.andThen(git.fetchRemote(input)), + ), + resolveRemoteTrackingCommit: (input) => + ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( + Effect.andThen(git.resolveRemoteTrackingCommit(input)), + ), removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 77a4dbde25f..ef024becfef 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5918,6 +5918,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { () => Effect.gen(function* () { const dispatchedCommands: Array = []; + const bootstrapGitOperations: string[] = []; const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, @@ -5936,13 +5937,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("fetch"); + }), + ); + const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567"; + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("resolve-remote-commit"); + return { + commitSha: fetchedOriginCommit, + remoteRefName: "origin/main", + }; + }), + ); const createWorktree = vi.fn( (_: Parameters[0]) => - Effect.succeed({ - worktree: { - refName: "t3code/bootstrap-refName", - path: "/tmp/bootstrap-worktree", - }, + Effect.sync(() => { + bootstrapGitOperations.push("create-worktree"); + return { + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }; }), ); const runForThread = vi.fn( @@ -5959,6 +5980,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + fetchRemote, + resolveRemoteTrackingCommit, createWorktree, }, vcsStatusBroadcaster: { @@ -6010,6 +6033,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", baseBranch: "main", branch: "t3code/bootstrap-refName", + fetchOrigin: true, }, runSetupScript: true, }, @@ -6031,10 +6055,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", - refName: "main", + refName: fetchedOriginCommit, newRefName: "t3code/bootstrap-refName", path: null, }); + assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.deepEqual(resolveRemoteTrackingCommit.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + fallbackRemoteName: "origin", + }); + assert.deepEqual(bootstrapGitOperations, [ + "fetch", + "resolve-remote-commit", + "create-worktree", + ]); assert.deepEqual(runForThread.mock.calls[0]?.[0], { threadId: ThreadId.make("thread-bootstrap"), projectId: defaultProjectId, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 66a5157ae83..ff0d644901d 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -161,6 +161,22 @@ export interface GitFetchRemoteTrackingBranchInput { remoteBranch: string; } +export interface GitFetchRemoteInput { + cwd: string; + remoteName: string; +} + +export interface GitResolveRemoteTrackingCommitInput { + cwd: string; + refName: string; + fallbackRemoteName: string; +} + +export interface GitResolveRemoteTrackingCommitResult { + commitSha: string; + remoteRefName: string; +} + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -217,6 +233,10 @@ export interface GitVcsDriverShape { ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly resolveRemoteTrackingCommit: ( + input: GitResolveRemoteTrackingCommitInput, + ) => Effect.Effect; readonly fetchRemoteBranch: ( input: GitFetchRemoteBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f4b2fe4d914..41f5d595f0a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -442,6 +442,69 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + it.effect("creates a worktree from the latest fetched remote commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const peer = yield* makeTmpDir("git-peer-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(remote, ["symbolic-ref", "HEAD", `refs/heads/${initialBranch}`]); + const beforeFetch = yield* git(cwd, ["rev-parse", `refs/remotes/origin/${initialBranch}`]); + + yield* git(peer, ["clone", remote, "."]); + yield* git(peer, ["config", "user.email", "test@test.com"]); + yield* git(peer, ["config", "user.name", "Test"]); + yield* writeTextFile(peer, "remote-change.txt", "remote\n"); + yield* git(peer, ["add", "remote-change.txt"]); + yield* git(peer, ["commit", "-m", "remote change"]); + yield* git(peer, ["push", "origin", initialBranch]); + const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]); + assert.notEqual(beforeFetch, remoteHead); + + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.fetchRemote({ cwd, remoteName: "origin" }); + + const resolvedBase = yield* driver.resolveRemoteTrackingCommit({ + cwd, + refName: initialBranch, + fallbackRemoteName: "origin", + }); + const explicitlyResolvedBase = yield* driver.resolveRemoteTrackingCommit({ + cwd, + refName: `origin/${initialBranch}`, + fallbackRemoteName: "origin", + }); + + assert.deepEqual(resolvedBase, { + commitSha: remoteHead, + remoteRefName: `origin/${initialBranch}`, + }); + assert.deepEqual(explicitlyResolvedBase, resolvedBase); + assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch); + + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-fetched-worktrees-"), + "fetched-origin", + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: resolvedBase.commitSha, + newRefName: "t3code/fetched-origin", + }); + + assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"), + null, + ); + }), + ); + it.effect("pushes with upstream setup and skips when already up to date", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 69550e0e7e5..5c24072052d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2188,6 +2188,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); + const fetchRemote: GitVcsDriver.GitVcsDriverShape["fetchRemote"] = Effect.fn("fetchRemote")( + function* (input) { + yield* executeGit( + "GitVcsDriver.fetchRemote", + input.cwd, + ["fetch", "--quiet", input.remoteName], + { + env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorMessage: `git fetch ${input.remoteName} failed`, + }, + ); + }, + ); + + const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"] = + Effect.fn("resolveRemoteTrackingCommit")(function* (input) { + const remoteNames = yield* listRemoteNames(input.cwd); + const parsedRemoteRef = parseRemoteRefWithRemoteNames( + input.refName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const remoteRefName = + parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`; + const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [ + "rev-parse", + "--verify", + `refs/remotes/${remoteRefName}^{commit}`, + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha, remoteRefName }; + }); + const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { @@ -2413,6 +2445,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fetchPullRequestBranch, ensureRemote, resolvePrimaryRemoteName, + fetchRemote, + resolveRemoteTrackingCommit, fetchRemoteBranch, fetchRemoteTrackingBranch, setBranchUpstream, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 34c993de84f..f3cd76e5b9c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -694,9 +694,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => } if (bootstrap?.prepareWorktree) { + let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; + if (bootstrap.prepareWorktree.fetchOrigin) { + yield* gitWorkflow.fetchRemote({ + cwd: bootstrap.prepareWorktree.projectCwd, + remoteName: "origin", + }); + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: bootstrap.prepareWorktree.projectCwd, + refName: bootstrap.prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } const worktree = yield* gitWorkflow.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + refName: worktreeBaseRef, newRefName: bootstrap.prepareWorktree.branch, path: null, }); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d9b0989b684..2d55390ea45 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -45,6 +45,8 @@ interface BranchToolbarProps { effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; + fetchOrigin: boolean; + onFetchOriginChange: (fetchOrigin: boolean) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -196,6 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, + fetchOrigin, + onFetchOriginChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -279,6 +283,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} + fetchOrigin={fetchOrigin} + onFetchOriginChange={onFetchOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index fd2c2b8c250..facd81418ed 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -5,11 +5,12 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { useCallback, useDeferredValue, useEffect, + useId, useLayoutEffect, useMemo, useOptimistic, @@ -37,6 +38,8 @@ import { shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Combobox, ComboboxEmpty, @@ -58,6 +61,8 @@ interface BranchToolbarBranchSelectorProps { effectiveEnvModeOverride?: "local" | "worktree"; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (refName: string | null) => void; + fetchOrigin: boolean; + onFetchOriginChange: (fetchOrigin: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -90,9 +95,12 @@ export function BranchToolbarBranchSelector({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, + fetchOrigin, + onFetchOriginChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const fetchOriginSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -674,6 +682,34 @@ export function BranchToolbarBranchSelector({ /> + {isSelectingWorktreeBase ? ( + + + + + onFetchOriginChange(Boolean(checked))} + /> + + } + /> + + Creates the worktree from the latest matching branch on origin instead of your local + branch. + + + ) : null} {branchStatusText ? {branchStatusText} : null} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 90a6dcdc338..e46b5958131 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1129,6 +1129,8 @@ function ChatViewContent(props: ChatViewProps) { const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); + const [pendingServerThreadFetchOriginByThreadId, setPendingServerThreadFetchOriginByThreadId] = + useState>({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -3337,6 +3339,12 @@ function ChatViewContent(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); + const fetchOrigin = isLocalDraftThread + ? (draftThread?.fetchOrigin ?? false) + : canOverrideServerThreadEnvMode + ? (pendingServerThreadFetchOriginByThreadId[activeThread?.id ?? ""] ?? + settings.defaultWorktreeFetchOrigin) + : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, @@ -3899,6 +3907,7 @@ function ChatViewContent(props: ChatViewProps) { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, branch: buildTemporaryWorktreeBranchName(randomHex), + ...(fetchOrigin ? { fetchOrigin: true } : {}), }, runSetupScript: true, } @@ -4600,6 +4609,22 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const onFetchOriginChange = (nextFetchOrigin: boolean) => { + if (canOverrideServerThreadEnvMode && activeThread) { + setPendingServerThreadFetchOriginByThreadId((current) => + current[activeThread.id] === nextFetchOrigin + ? current + : { ...current, [activeThread.id]: nextFetchOrigin }, + ); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + fetchOrigin: nextFetchOrigin, + }); + } + }; + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -4933,6 +4958,8 @@ function ChatViewContent(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} + fetchOrigin={fetchOrigin} + onFetchOriginChange={onFetchOriginChange} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} {...(canOverrideServerThreadEnvMode ? { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f478eac7d96..957975a11b9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -408,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.defaultWorktreeFetchOrigin !== + DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin + ? ["New worktree origin base"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -426,6 +430,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.defaultWorktreeFetchOrigin, settings.diffIgnoreWhitespace, settings.diffWordWrap, settings.automaticGitFetchInterval, @@ -456,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) { enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + defaultWorktreeFetchOrigin: DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -692,12 +698,15 @@ export function GeneralSettingsPanel() { title="New threads" description="Pick the default workspace mode for newly created draft threads." resetAction={ - settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ( + settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode || + settings.defaultWorktreeFetchOrigin !== + DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin ? ( updateSettings({ defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + defaultWorktreeFetchOrigin: DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin, }) } /> @@ -729,6 +738,37 @@ export function GeneralSettingsPanel() { } /> + {settings.defaultThreadEnvMode === "worktree" ? ( + + updateSettings({ + defaultWorktreeFetchOrigin: + DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin, + }) + } + /> + ) : null + } + control={ + + updateSettings({ defaultWorktreeFetchOrigin: Boolean(checked) }) + } + aria-label="Start new worktrees from origin by default" + /> + } + /> + ) : null} + { }); }); + it("stores the fetch-origin choice with the draft thread", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + envMode: "worktree", + fetchOrigin: true, + }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.fetchOrigin).toBe(true); + + store.setDraftThreadContext(draftId, { fetchOrigin: false }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.fetchOrigin).toBe(false); + }); + it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index b92595227a6..1dafb270e3a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -27,6 +27,7 @@ import { } from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; +import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; @@ -214,6 +215,7 @@ const PersistedDraftThreadState = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, + fetchOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -292,6 +294,7 @@ export interface DraftSessionState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + fetchOrigin: boolean; promotedTo?: ScopedThreadRef | null; } @@ -353,6 +356,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + fetchOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -367,6 +371,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + fetchOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -380,6 +385,7 @@ interface ComposerDraftStoreState { projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; + fetchOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1313,6 +1319,7 @@ function createDraftThreadState( worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + fetchOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1333,6 +1340,12 @@ function createDraftThreadState( ? null : (existingThread?.branch ?? null) : (options.branch ?? null); + const nextFetchOrigin = + options?.fetchOrigin === undefined + ? projectChanged + ? false + : (existingThread?.fetchOrigin ?? false) + : options.fetchOrigin; return { threadId, environmentId: projectRef.environmentId, @@ -1351,6 +1364,7 @@ function createDraftThreadState( : projectChanged ? "local" : (existingThread?.envMode ?? "local")), + fetchOrigin: nextFetchOrigin, promotedTo: null, }; } @@ -1382,6 +1396,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.branch === right.branch && left.worktreePath === right.worktreePath && left.envMode === right.envMode && + left.fetchOrigin === right.fetchOrigin && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1476,6 +1491,7 @@ function normalizePersistedDraftThreads( const createdAt = candidateDraftThread.createdAt; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; + const fetchOrigin = candidateDraftThread.fetchOrigin === true; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1523,6 +1539,7 @@ function normalizePersistedDraftThreads( branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), + fetchOrigin, promotedTo, }; } @@ -1568,6 +1585,7 @@ function normalizePersistedDraftThreads( branch: null, worktreePath: null, envMode: "local", + fetchOrigin: false, promotedTo: null, }; } else if ( @@ -2138,6 +2156,7 @@ function toHydratedDraftThreadState( branch: persistedDraftThread.branch, worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, + fetchOrigin: persistedDraftThread.fetchOrigin, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2323,6 +2342,12 @@ const composerDraftStore = create()( ? null : existing.branch : (options.branch ?? null); + const nextFetchOrigin = + options.fetchOrigin === undefined + ? projectChanged + ? false + : existing.fetchOrigin + : options.fetchOrigin; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2343,6 +2368,7 @@ const composerDraftStore = create()( : projectChanged ? "local" : (existing.envMode ?? "local")), + fetchOrigin: nextFetchOrigin, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2355,6 +2381,7 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && + nextDraftThread.fetchOrigin === existing.fetchOrigin && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 0b802dd8736..5911c344260 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -20,6 +20,7 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { readThreadShell, useProjects, useThread } from "../state/entities"; +import { resolveNewDraftFetchOrigin } from "../lib/chatThreadActions"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useSettings } from "./useSettings"; @@ -27,6 +28,7 @@ import { useSettings } from "./useSettings"; export function useNewThreadHandler() { const projects = useProjects(); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const defaultWorktreeFetchOrigin = useSettings((settings) => settings.defaultWorktreeFetchOrigin); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -40,6 +42,7 @@ export function useNewThreadHandler() { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; + fetchOrigin?: boolean; }, ): Promise => { const { @@ -62,6 +65,7 @@ export function useNewThreadHandler() { const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; + const hasFetchOriginOption = options?.fetchOrigin !== undefined; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -80,11 +84,17 @@ export function useNewThreadHandler() { : null; if (reusableStoredDraftThread) { return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + if ( + hasBranchOption || + hasWorktreePathOption || + hasEnvModeOption || + hasFetchOriginOption + ) { setDraftThreadContext(reusableStoredDraftThread.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasFetchOriginOption ? { fetchOrigin: options?.fetchOrigin } : {}), }); } setLogicalProjectDraftThreadId( @@ -114,11 +124,12 @@ export function useNewThreadHandler() { latestActiveDraftThread.logicalProjectKey === logicalProjectKey && latestActiveDraftThread.promotedTo == null ) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption || hasFetchOriginOption) { setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasFetchOriginOption ? { fetchOrigin: options?.fetchOrigin } : {}), }); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { @@ -129,6 +140,7 @@ export function useNewThreadHandler() { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasFetchOriginOption ? { fetchOrigin: options?.fetchOrigin } : {}), }); return Promise.resolve(); } @@ -136,13 +148,20 @@ export function useNewThreadHandler() { const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); + const initialEnvMode = options?.envMode ?? "local"; return (async () => { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", + envMode: initialEnvMode, + fetchOrigin: + options?.fetchOrigin ?? + resolveNewDraftFetchOrigin({ + envMode: initialEnvMode, + defaultWorktreeFetchOrigin, + }), runtimeMode: DEFAULT_RUNTIME_MODE, }); applyStickyState(draftId); @@ -153,7 +172,7 @@ export function useNewThreadHandler() { }); })(); }, - [getCurrentRouteTarget, projectGroupingSettings, router, projects], + [defaultWorktreeFetchOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 45d22b1df91..e5a1a980aa4 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, + resolveNewDraftFetchOrigin, startNewLocalThreadFromContext, startNewThreadFromContext, type ChatThreadActionContext, @@ -24,6 +25,21 @@ function createContext(overrides: Partial = {}): ChatTh } describe("chatThreadActions", () => { + it("only applies the fetch-origin default to new worktree drafts", () => { + expect( + resolveNewDraftFetchOrigin({ + envMode: "worktree", + defaultWorktreeFetchOrigin: true, + }), + ).toBe(true); + expect( + resolveNewDraftFetchOrigin({ + envMode: "local", + defaultWorktreeFetchOrigin: true, + }), + ).toBe(false); + }); + it("prefers the active draft thread project when resolving thread actions", () => { const projectRef = resolveThreadActionProjectRef( createContext({ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index b434d1f519f..309cd58ae91 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -34,6 +34,13 @@ export interface ChatThreadActionContext { readonly handleNewThread: NewThreadHandler; } +export function resolveNewDraftFetchOrigin(input: { + envMode: DraftThreadEnvMode; + defaultWorktreeFetchOrigin: boolean; +}): boolean { + return input.envMode === "worktree" && input.defaultWorktreeFetchOrigin; +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null { diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 3dc83933e38..aff850b0e80 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -277,6 +277,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => projectCwd: "/tmp/workspace", baseBranch: "main", branch: "t3code/example", + fetchOrigin: true, }, runSetupScript: true, }, @@ -284,6 +285,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => }); assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); + assert.strictEqual(parsed.bootstrap?.prepareWorktree?.fetchOrigin, true); assert.strictEqual(parsed.bootstrap?.runSetupScript, true); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 46d51da371f..cd86fe2707b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -565,6 +565,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ projectCwd: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, branch: Schema.optional(TrimmedNonEmptyString), + fetchOrigin: Schema.optional(Schema.Boolean), }); const ThreadTurnStartBootstrap = Schema.Struct({ diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 04ee479bcd3..a775c22bc11 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -64,6 +64,18 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); }); +describe("ServerSettings worktree defaults", () => { + it("defaults fetch origin off for legacy configs", () => { + expect(decodeServerSettings({}).defaultWorktreeFetchOrigin).toBe(false); + }); + + it("accepts fetch origin updates", () => { + expect( + decodeServerSettingsPatch({ defaultWorktreeFetchOrigin: true }).defaultWorktreeFetchOrigin, + ).toBe(true); + }); +}); + describe("ServerSettingsPatch.providerInstances", () => { it("treats providerInstances as an optional whole-map replacement", () => { const patch = decodeServerSettingsPatch({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6955ab7050f..c19d737d6ba 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -373,6 +373,9 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + defaultWorktreeFetchOrigin: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -481,6 +484,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + defaultWorktreeFetchOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( From 2e014ea4323bff46951cfe619a226ff8d483bbf2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 18 Jun 2026 20:35:06 -0700 Subject: [PATCH 2/2] Rename worktree origin flag to startFromOrigin - Rename fetchOrigin settings and draft fields across server, web, and contracts - Preserve and surface the origin choice for new worktree drafts --- apps/server/src/server.test.ts | 2 +- apps/server/src/ws.ts | 2 +- apps/web/src/components/BranchToolbar.tsx | 12 ++--- .../BranchToolbarBranchSelector.tsx | 18 ++++---- apps/web/src/components/ChatView.tsx | 36 +++++++++------ apps/web/src/components/Sidebar.logic.test.ts | 3 ++ apps/web/src/components/Sidebar.logic.ts | 3 ++ apps/web/src/components/Sidebar.tsx | 4 ++ .../components/settings/SettingsPanels.tsx | 31 ++++++------- apps/web/src/composerDraftStore.test.ts | 10 ++--- apps/web/src/composerDraftStore.ts | 44 +++++++++---------- apps/web/src/hooks/useHandleNewThread.ts | 35 +++++++++------ apps/web/src/lib/chatThreadActions.test.ts | 40 ++++++++++++++--- apps/web/src/lib/chatThreadActions.ts | 11 +++-- packages/contracts/src/orchestration.test.ts | 4 +- packages/contracts/src/orchestration.ts | 2 +- packages/contracts/src/settings.test.ts | 8 ++-- packages/contracts/src/settings.ts | 4 +- 18 files changed, 164 insertions(+), 105 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ef024becfef..205833289ea 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6033,7 +6033,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", baseBranch: "main", branch: "t3code/bootstrap-refName", - fetchOrigin: true, + startFromOrigin: true, }, runSetupScript: true, }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f3cd76e5b9c..1ad37e7c49b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -695,7 +695,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => if (bootstrap?.prepareWorktree) { let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.fetchOrigin) { + if (bootstrap.prepareWorktree.startFromOrigin) { yield* gitWorkflow.fetchRemote({ cwd: bootstrap.prepareWorktree.projectCwd, remoteName: "origin", diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 2d55390ea45..03f24dac8e9 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -45,8 +45,8 @@ interface BranchToolbarProps { effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; - fetchOrigin: boolean; - onFetchOriginChange: (fetchOrigin: boolean) => void; + startFromOrigin: boolean; + onStartFromOriginChange: (startFromOrigin: boolean) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -198,8 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, - fetchOrigin, - onFetchOriginChange, + startFromOrigin, + onStartFromOriginChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -283,8 +283,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} - fetchOrigin={fetchOrigin} - onFetchOriginChange={onFetchOriginChange} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index facd81418ed..f8a2e1a6fcd 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -61,8 +61,8 @@ interface BranchToolbarBranchSelectorProps { effectiveEnvModeOverride?: "local" | "worktree"; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (refName: string | null) => void; - fetchOrigin: boolean; - onFetchOriginChange: (fetchOrigin: boolean) => void; + startFromOrigin: boolean; + onStartFromOriginChange: (startFromOrigin: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -95,12 +95,12 @@ export function BranchToolbarBranchSelector({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, - fetchOrigin, - onFetchOriginChange, + startFromOrigin, + onStartFromOriginChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { - const fetchOriginSwitchId = useId(); + const startFromOriginSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -687,7 +687,7 @@ export function BranchToolbarBranchSelector({ @@ -695,11 +695,11 @@ export function BranchToolbarBranchSelector({ Start from origin onFetchOriginChange(Boolean(checked))} + onCheckedChange={(checked) => onStartFromOriginChange(Boolean(checked))} /> } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e46b5958131..c3f291afa21 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -141,6 +141,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -1129,8 +1130,10 @@ function ChatViewContent(props: ChatViewProps) { const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); - const [pendingServerThreadFetchOriginByThreadId, setPendingServerThreadFetchOriginByThreadId] = - useState>({}); + const [ + pendingServerThreadStartFromOriginByThreadId, + setPendingServerThreadStartFromOriginByThreadId, + ] = useState>({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -3339,11 +3342,11 @@ function ChatViewContent(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); - const fetchOrigin = isLocalDraftThread - ? (draftThread?.fetchOrigin ?? false) + const startFromOrigin = isLocalDraftThread + ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode - ? (pendingServerThreadFetchOriginByThreadId[activeThread?.id ?? ""] ?? - settings.defaultWorktreeFetchOrigin) + ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? + settings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -3907,7 +3910,7 @@ function ChatViewContent(props: ChatViewProps) { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, branch: buildTemporaryWorktreeBranchName(randomHex), - ...(fetchOrigin ? { fetchOrigin: true } : {}), + ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, } @@ -4593,6 +4596,10 @@ function ChatViewContent(props: ChatViewProps) { if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { envMode: mode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: mode, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, + }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); } @@ -4603,24 +4610,25 @@ function ChatViewContent(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, + settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, ], ); - const onFetchOriginChange = (nextFetchOrigin: boolean) => { + const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { if (canOverrideServerThreadEnvMode && activeThread) { - setPendingServerThreadFetchOriginByThreadId((current) => - current[activeThread.id] === nextFetchOrigin + setPendingServerThreadStartFromOriginByThreadId((current) => + current[activeThread.id] === nextStartFromOrigin ? current - : { ...current, [activeThread.id]: nextFetchOrigin }, + : { ...current, [activeThread.id]: nextStartFromOrigin }, ); return; } if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { - fetchOrigin: nextFetchOrigin, + startFromOrigin: nextStartFromOrigin, }); } }; @@ -4958,8 +4966,8 @@ function ChatViewContent(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} - fetchOrigin={fetchOrigin} - onFetchOriginChange={onFetchOriginChange} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} {...(canOverrideServerThreadEnvMode ? { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 61fae76f8ef..b1c29888f9b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -237,6 +237,7 @@ describe("resolveSidebarNewThreadSeedContext", () => { branch: "feature/draft", worktreePath: "/repo/.t3/worktrees/draft", envMode: "worktree", + startFromOrigin: true, }, }), ).toEqual({ @@ -278,12 +279,14 @@ describe("resolveSidebarNewThreadSeedContext", () => { branch: "feature/new-draft", worktreePath: "/repo/worktree", envMode: "worktree", + startFromOrigin: true, }, }), ).toEqual({ branch: "feature/new-draft", worktreePath: "/repo/worktree", envMode: "worktree", + startFromOrigin: true, }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 0ca86ae8f32..f628e21e4a4 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -189,11 +189,13 @@ export function resolveSidebarNewThreadSeedContext(input: { 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 { @@ -206,6 +208,7 @@ export function resolveSidebarNewThreadSeedContext(input: { branch: input.activeDraftThread.branch, worktreePath: input.activeDraftThread.worktreePath, envMode: input.activeDraftThread.envMode, + startFromOrigin: input.activeDraftThread.startFromOrigin, }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b943fb5a69d..1b46b0f1d04 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1875,6 +1875,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec branch: currentActiveDraftThread.branch, worktreePath: currentActiveDraftThread.worktreePath, envMode: currentActiveDraftThread.envMode, + startFromOrigin: currentActiveDraftThread.startFromOrigin, } : null, }); @@ -1889,6 +1890,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? { worktreePath: seedContext.worktreePath } : {}), envMode: seedContext.envMode, + ...(seedContext.startFromOrigin !== undefined + ? { startFromOrigin: seedContext.startFromOrigin } + : {}), }), ); if (result._tag === "Failure") { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 957975a11b9..71311c10d5c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -408,9 +408,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), - ...(settings.defaultWorktreeFetchOrigin !== - DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin - ? ["New worktree origin base"] + ...(settings.newWorktreesStartFromOrigin !== + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin + ? ["New worktrees start from origin"] : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] @@ -430,7 +430,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, - settings.defaultWorktreeFetchOrigin, + settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.diffWordWrap, settings.automaticGitFetchInterval, @@ -461,7 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) { enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - defaultWorktreeFetchOrigin: DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin, + newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -699,14 +699,15 @@ export function GeneralSettingsPanel() { description="Pick the default workspace mode for newly created draft threads." resetAction={ settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode || - settings.defaultWorktreeFetchOrigin !== - DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin ? ( + settings.newWorktreesStartFromOrigin !== + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ( updateSettings({ defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - defaultWorktreeFetchOrigin: DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin, + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, }) } /> @@ -744,14 +745,14 @@ export function GeneralSettingsPanel() { title="Start from origin" description="Creates the worktree from the latest matching branch on origin instead of your local branch." resetAction={ - settings.defaultWorktreeFetchOrigin !== - DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin ? ( + settings.newWorktreesStartFromOrigin !== + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ( updateSettings({ - defaultWorktreeFetchOrigin: - DEFAULT_UNIFIED_SETTINGS.defaultWorktreeFetchOrigin, + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, }) } /> @@ -759,9 +760,9 @@ export function GeneralSettingsPanel() { } control={ - updateSettings({ defaultWorktreeFetchOrigin: Boolean(checked) }) + updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) } aria-label="Start new worktrees from origin by default" /> diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc7021b3d00..bc1b7107306 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1035,19 +1035,19 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); - it("stores the fetch-origin choice with the draft thread", () => { + it("stores the start-from-origin choice with the draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId, envMode: "worktree", - fetchOrigin: true, + startFromOrigin: true, }); - expect(useComposerDraftStore.getState().getDraftThread(draftId)?.fetchOrigin).toBe(true); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(true); - store.setDraftThreadContext(draftId, { fetchOrigin: false }); + store.setDraftThreadContext(draftId, { startFromOrigin: false }); - expect(useComposerDraftStore.getState().getDraftThread(draftId)?.fetchOrigin).toBe(false); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(false); }); it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 1dafb270e3a..fdb8bfe7b18 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -215,7 +215,7 @@ const PersistedDraftThreadState = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, - fetchOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -294,7 +294,7 @@ export interface DraftSessionState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; - fetchOrigin: boolean; + startFromOrigin: boolean; promotedTo?: ScopedThreadRef | null; } @@ -356,7 +356,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; - fetchOrigin?: boolean; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -371,7 +371,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; - fetchOrigin?: boolean; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -385,7 +385,7 @@ interface ComposerDraftStoreState { projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; - fetchOrigin?: boolean; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1319,7 +1319,7 @@ function createDraftThreadState( worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; - fetchOrigin?: boolean; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1340,12 +1340,12 @@ function createDraftThreadState( ? null : (existingThread?.branch ?? null) : (options.branch ?? null); - const nextFetchOrigin = - options?.fetchOrigin === undefined + const nextStartFromOrigin = + options?.startFromOrigin === undefined ? projectChanged ? false - : (existingThread?.fetchOrigin ?? false) - : options.fetchOrigin; + : (existingThread?.startFromOrigin ?? false) + : options.startFromOrigin; return { threadId, environmentId: projectRef.environmentId, @@ -1364,7 +1364,7 @@ function createDraftThreadState( : projectChanged ? "local" : (existingThread?.envMode ?? "local")), - fetchOrigin: nextFetchOrigin, + startFromOrigin: nextStartFromOrigin, promotedTo: null, }; } @@ -1396,7 +1396,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.branch === right.branch && left.worktreePath === right.worktreePath && left.envMode === right.envMode && - left.fetchOrigin === right.fetchOrigin && + left.startFromOrigin === right.startFromOrigin && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1491,7 +1491,7 @@ function normalizePersistedDraftThreads( const createdAt = candidateDraftThread.createdAt; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; - const fetchOrigin = candidateDraftThread.fetchOrigin === true; + const startFromOrigin = candidateDraftThread.startFromOrigin === true; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1539,7 +1539,7 @@ function normalizePersistedDraftThreads( branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), - fetchOrigin, + startFromOrigin, promotedTo, }; } @@ -1585,7 +1585,7 @@ function normalizePersistedDraftThreads( branch: null, worktreePath: null, envMode: "local", - fetchOrigin: false, + startFromOrigin: false, promotedTo: null, }; } else if ( @@ -2156,7 +2156,7 @@ function toHydratedDraftThreadState( branch: persistedDraftThread.branch, worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, - fetchOrigin: persistedDraftThread.fetchOrigin, + startFromOrigin: persistedDraftThread.startFromOrigin, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2342,12 +2342,12 @@ const composerDraftStore = create()( ? null : existing.branch : (options.branch ?? null); - const nextFetchOrigin = - options.fetchOrigin === undefined + const nextStartFromOrigin = + options.startFromOrigin === undefined ? projectChanged ? false - : existing.fetchOrigin - : options.fetchOrigin; + : existing.startFromOrigin + : options.startFromOrigin; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2368,7 +2368,7 @@ const composerDraftStore = create()( : projectChanged ? "local" : (existing.envMode ?? "local")), - fetchOrigin: nextFetchOrigin, + startFromOrigin: nextStartFromOrigin, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2381,7 +2381,7 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && - nextDraftThread.fetchOrigin === existing.fetchOrigin && + nextDraftThread.startFromOrigin === existing.startFromOrigin && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 5911c344260..c99ae0af9b8 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -20,7 +20,7 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { readThreadShell, useProjects, useThread } from "../state/entities"; -import { resolveNewDraftFetchOrigin } from "../lib/chatThreadActions"; +import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useSettings } from "./useSettings"; @@ -28,7 +28,9 @@ import { useSettings } from "./useSettings"; export function useNewThreadHandler() { const projects = useProjects(); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); - const defaultWorktreeFetchOrigin = useSettings((settings) => settings.defaultWorktreeFetchOrigin); + const newWorktreesStartFromOrigin = useSettings( + (settings) => settings.newWorktreesStartFromOrigin, + ); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -42,7 +44,7 @@ export function useNewThreadHandler() { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; - fetchOrigin?: boolean; + startFromOrigin?: boolean; }, ): Promise => { const { @@ -65,7 +67,7 @@ export function useNewThreadHandler() { const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; - const hasFetchOriginOption = options?.fetchOrigin !== undefined; + const hasStartFromOriginOption = options?.startFromOrigin !== undefined; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -88,13 +90,13 @@ export function useNewThreadHandler() { hasBranchOption || hasWorktreePathOption || hasEnvModeOption || - hasFetchOriginOption + hasStartFromOriginOption ) { setDraftThreadContext(reusableStoredDraftThread.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasFetchOriginOption ? { fetchOrigin: options?.fetchOrigin } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); } setLogicalProjectDraftThreadId( @@ -124,12 +126,17 @@ export function useNewThreadHandler() { latestActiveDraftThread.logicalProjectKey === logicalProjectKey && latestActiveDraftThread.promotedTo == null ) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption || hasFetchOriginOption) { + if ( + hasBranchOption || + hasWorktreePathOption || + hasEnvModeOption || + hasStartFromOriginOption + ) { setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasFetchOriginOption ? { fetchOrigin: options?.fetchOrigin } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { @@ -140,7 +147,7 @@ export function useNewThreadHandler() { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasFetchOriginOption ? { fetchOrigin: options?.fetchOrigin } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); return Promise.resolve(); } @@ -156,11 +163,11 @@ export function useNewThreadHandler() { branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, envMode: initialEnvMode, - fetchOrigin: - options?.fetchOrigin ?? - resolveNewDraftFetchOrigin({ + startFromOrigin: + options?.startFromOrigin ?? + resolveNewDraftStartFromOrigin({ envMode: initialEnvMode, - defaultWorktreeFetchOrigin, + newWorktreesStartFromOrigin, }), runtimeMode: DEFAULT_RUNTIME_MODE, }); @@ -172,7 +179,7 @@ export function useNewThreadHandler() { }); })(); }, - [defaultWorktreeFetchOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects], + [newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index e5a1a980aa4..62e5aa41d43 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -3,7 +3,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, - resolveNewDraftFetchOrigin, + resolveNewDraftStartFromOrigin, startNewLocalThreadFromContext, startNewThreadFromContext, type ChatThreadActionContext, @@ -25,17 +25,17 @@ function createContext(overrides: Partial = {}): ChatTh } describe("chatThreadActions", () => { - it("only applies the fetch-origin default to new worktree drafts", () => { + it("only applies the start-from-origin default to new worktree drafts", () => { expect( - resolveNewDraftFetchOrigin({ + resolveNewDraftStartFromOrigin({ envMode: "worktree", - defaultWorktreeFetchOrigin: true, + newWorktreesStartFromOrigin: true, }), ).toBe(true); expect( - resolveNewDraftFetchOrigin({ + resolveNewDraftStartFromOrigin({ envMode: "local", - defaultWorktreeFetchOrigin: true, + newWorktreesStartFromOrigin: true, }), ).toBe(false); }); @@ -49,6 +49,7 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, }, }), ); @@ -77,6 +78,7 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, }, handleNewThread, }), @@ -87,6 +89,32 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, + }); + }); + + it("preserves an explicitly disabled origin base in contextual thread options", async () => { + const handleNewThread = vi.fn(async () => {}); + + await startNewThreadFromContext( + createContext({ + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + startFromOrigin: false, + }, + handleNewThread, + }), + ); + + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + startFromOrigin: false, }); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 309cd58ae91..63d0289d104 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -11,6 +11,7 @@ interface ThreadContextLike { interface DraftThreadContextLike extends ThreadContextLike { envMode: DraftThreadEnvMode; + startFromOrigin: boolean; } interface NewThreadHandler { @@ -20,6 +21,7 @@ interface NewThreadHandler { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; }, ): Promise; } @@ -34,11 +36,11 @@ export interface ChatThreadActionContext { readonly handleNewThread: NewThreadHandler; } -export function resolveNewDraftFetchOrigin(input: { +export function resolveNewDraftStartFromOrigin(input: { envMode: DraftThreadEnvMode; - defaultWorktreeFetchOrigin: boolean; + newWorktreesStartFromOrigin: boolean; }): boolean { - return input.envMode === "worktree" && input.defaultWorktreeFetchOrigin; + return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } export function resolveThreadActionProjectRef( @@ -64,6 +66,9 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre envMode: context.activeDraftThread?.envMode ?? (context.activeThread?.worktreePath ? "worktree" : "local"), + ...(context.activeDraftThread + ? { startFromOrigin: context.activeDraftThread.startFromOrigin } + : {}), }; } diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index aff850b0e80..29a732ca69b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -277,7 +277,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => projectCwd: "/tmp/workspace", baseBranch: "main", branch: "t3code/example", - fetchOrigin: true, + startFromOrigin: true, }, runSetupScript: true, }, @@ -285,7 +285,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => }); assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); - assert.strictEqual(parsed.bootstrap?.prepareWorktree?.fetchOrigin, true); + assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true); assert.strictEqual(parsed.bootstrap?.runSetupScript, true); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cd86fe2707b..623fed0917b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -565,7 +565,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ projectCwd: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, branch: Schema.optional(TrimmedNonEmptyString), - fetchOrigin: Schema.optional(Schema.Boolean), + startFromOrigin: Schema.optional(Schema.Boolean), }); const ThreadTurnStartBootstrap = Schema.Struct({ diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index a775c22bc11..aba97cbe205 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -65,13 +65,13 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); describe("ServerSettings worktree defaults", () => { - it("defaults fetch origin off for legacy configs", () => { - expect(decodeServerSettings({}).defaultWorktreeFetchOrigin).toBe(false); + it("defaults start-from-origin off for legacy configs", () => { + expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false); }); - it("accepts fetch origin updates", () => { + it("accepts start-from-origin updates", () => { expect( - decodeServerSettingsPatch({ defaultWorktreeFetchOrigin: true }).defaultWorktreeFetchOrigin, + decodeServerSettingsPatch({ newWorktreesStartFromOrigin: true }).newWorktreesStartFromOrigin, ).toBe(true); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c19d737d6ba..0463a441759 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -373,7 +373,7 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), - defaultWorktreeFetchOrigin: Schema.Boolean.pipe( + newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -484,7 +484,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), - defaultWorktreeFetchOrigin: Schema.optionalKey(Schema.Boolean), + newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey(