authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-18 20:39:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-18 20:39:52-07:00
log52a24c890265967aa31fa03c4339210022ac403d
treed54695bedaa8b84fc7832394b34e51f1da0665ec
parent30034ecedc467ea2e00075f8cd2abd11ef8dd990
signature Signed by PGP key B5690EEEBB952194

Add origin-based worktree bootstrap option (#3157)


22 files changed, 472 insertions(+), 13 deletions(-)

apps/server/src/git/GitWorkflowService.ts+20
......@@ -61,6 +61,18 @@ export interface GitWorkflowServiceShape {
6161 readonly createWorktree: (
6262 input: VcsCreateWorktreeInput,
6363 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
64 readonly fetchRemote: (input: {
65 readonly cwd: string;
66 readonly remoteName: string;
67 }) => Effect.Effect<void, GitCommandError>;
68 readonly resolveRemoteTrackingCommit: (input: {
69 readonly cwd: string;
70 readonly refName: string;
71 readonly fallbackRemoteName: string;
72 }) => Effect.Effect<
73 { readonly commitSha: string; readonly remoteRefName: string },
74 GitCommandError
75 >;
6476 readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
6577 readonly createRef: (
6678 input: VcsCreateRefInput,
......@@ -295,6 +307,14 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
295307 ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
296308 Effect.andThen(git.createWorktree(input)),
297309 ),
310 fetchRemote: (input) =>
311 ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe(
312 Effect.andThen(git.fetchRemote(input)),
313 ),
314 resolveRemoteTrackingCommit: (input) =>
315 ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe(
316 Effect.andThen(git.resolveRemoteTrackingCommit(input)),
317 ),
298318 removeWorktree: (input) =>
299319 ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
300320 Effect.andThen(git.removeWorktree(input)),
apps/server/src/server.test.ts+44-6
......@@ -5918,6 +5918,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
59185918 () =>
59195919 Effect.gen(function* () {
59205920 const dispatchedCommands: Array<OrchestrationCommand> = [];
5921 const bootstrapGitOperations: string[] = [];
59215922 const refreshStatus = vi.fn((_: string) =>
59225923 Effect.succeed({
59235924 isRepo: true,
......@@ -5936,13 +5937,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
59365937 pr: null,
59375938 }),
59385939 );
5940 const fetchRemote = vi.fn(
5941 (_: Parameters<GitVcsDriver.GitVcsDriverShape["fetchRemote"]>[0]) =>
5942 Effect.sync(() => {
5943 bootstrapGitOperations.push("fetch");
5944 }),
5945 );
5946 const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567";
5947 const resolveRemoteTrackingCommit = vi.fn(
5948 (_: Parameters<GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"]>[0]) =>
5949 Effect.sync(() => {
5950 bootstrapGitOperations.push("resolve-remote-commit");
5951 return {
5952 commitSha: fetchedOriginCommit,
5953 remoteRefName: "origin/main",
5954 };
5955 }),
5956 );
59395957 const createWorktree = vi.fn(
59405958 (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) =>
5941 Effect.succeed({
5942 worktree: {
5943 refName: "t3code/bootstrap-refName",
5944 path: "/tmp/bootstrap-worktree",
5945 },
5959 Effect.sync(() => {
5960 bootstrapGitOperations.push("create-worktree");
5961 return {
5962 worktree: {
5963 refName: "t3code/bootstrap-refName",
5964 path: "/tmp/bootstrap-worktree",
5965 },
5966 };
59465967 }),
59475968 );
59485969 const runForThread = vi.fn(
......@@ -5959,6 +5980,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
59595980 yield* buildAppUnderTest({
59605981 layers: {
59615982 gitVcsDriver: {
5983 fetchRemote,
5984 resolveRemoteTrackingCommit,
59625985 createWorktree,
59635986 },
59645987 vcsStatusBroadcaster: {
......@@ -6010,6 +6033,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
60106033 projectCwd: "/tmp/project",
60116034 baseBranch: "main",
60126035 branch: "t3code/bootstrap-refName",
6036 startFromOrigin: true,
60136037 },
60146038 runSetupScript: true,
60156039 },
......@@ -6031,10 +6055,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
60316055 );
60326056 assert.deepEqual(createWorktree.mock.calls[0]?.[0], {
60336057 cwd: "/tmp/project",
6034 refName: "main",
6058 refName: fetchedOriginCommit,
60356059 newRefName: "t3code/bootstrap-refName",
60366060 path: null,
60376061 });
6062 assert.deepEqual(fetchRemote.mock.calls[0]?.[0], {
6063 cwd: "/tmp/project",
6064 remoteName: "origin",
6065 });
6066 assert.deepEqual(resolveRemoteTrackingCommit.mock.calls[0]?.[0], {
6067 cwd: "/tmp/project",
6068 refName: "main",
6069 fallbackRemoteName: "origin",
6070 });
6071 assert.deepEqual(bootstrapGitOperations, [
6072 "fetch",
6073 "resolve-remote-commit",
6074 "create-worktree",
6075 ]);
60386076 assert.deepEqual(runForThread.mock.calls[0]?.[0], {
60396077 threadId: ThreadId.make("thread-bootstrap"),
60406078 projectId: defaultProjectId,
apps/server/src/vcs/GitVcsDriver.ts+20
......@@ -161,6 +161,22 @@ export interface GitFetchRemoteTrackingBranchInput {
161161 remoteBranch: string;
162162}
163163
164export interface GitFetchRemoteInput {
165 cwd: string;
166 remoteName: string;
167}
168
169export interface GitResolveRemoteTrackingCommitInput {
170 cwd: string;
171 refName: string;
172 fallbackRemoteName: string;
173}
174
175export interface GitResolveRemoteTrackingCommitResult {
176 commitSha: string;
177 remoteRefName: string;
178}
179
164180export interface GitSetBranchUpstreamInput {
165181 cwd: string;
166182 branch: string;
......@@ -217,6 +233,10 @@ export interface GitVcsDriverShape {
217233 ) => Effect.Effect<void, GitCommandError>;
218234 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
219235 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
236 readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>;
237 readonly resolveRemoteTrackingCommit: (
238 input: GitResolveRemoteTrackingCommitInput,
239 ) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>;
220240 readonly fetchRemoteBranch: (
221241 input: GitFetchRemoteBranchInput,
222242 ) => Effect.Effect<void, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.test.ts+63
......@@ -442,6 +442,69 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
442442 });
443443
444444 describe("remote operations", () => {
445 it.effect("creates a worktree from the latest fetched remote commit", () =>
446 Effect.gen(function* () {
447 const cwd = yield* makeTmpDir();
448 const remote = yield* makeTmpDir("git-remote-");
449 const peer = yield* makeTmpDir("git-peer-");
450 const { initialBranch } = yield* initRepoWithCommit(cwd);
451 yield* git(remote, ["init", "--bare"]);
452 yield* git(cwd, ["remote", "add", "origin", remote]);
453 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
454 yield* git(remote, ["symbolic-ref", "HEAD", `refs/heads/${initialBranch}`]);
455 const beforeFetch = yield* git(cwd, ["rev-parse", `refs/remotes/origin/${initialBranch}`]);
456
457 yield* git(peer, ["clone", remote, "."]);
458 yield* git(peer, ["config", "user.email", "test@test.com"]);
459 yield* git(peer, ["config", "user.name", "Test"]);
460 yield* writeTextFile(peer, "remote-change.txt", "remote\n");
461 yield* git(peer, ["add", "remote-change.txt"]);
462 yield* git(peer, ["commit", "-m", "remote change"]);
463 yield* git(peer, ["push", "origin", initialBranch]);
464 const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]);
465 assert.notEqual(beforeFetch, remoteHead);
466
467 const driver = yield* GitVcsDriver.GitVcsDriver;
468 yield* driver.fetchRemote({ cwd, remoteName: "origin" });
469
470 const resolvedBase = yield* driver.resolveRemoteTrackingCommit({
471 cwd,
472 refName: initialBranch,
473 fallbackRemoteName: "origin",
474 });
475 const explicitlyResolvedBase = yield* driver.resolveRemoteTrackingCommit({
476 cwd,
477 refName: `origin/${initialBranch}`,
478 fallbackRemoteName: "origin",
479 });
480
481 assert.deepEqual(resolvedBase, {
482 commitSha: remoteHead,
483 remoteRefName: `origin/${initialBranch}`,
484 });
485 assert.deepEqual(explicitlyResolvedBase, resolvedBase);
486 assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch);
487
488 const pathService = yield* Path.Path;
489 const worktreePath = pathService.join(
490 yield* makeTmpDir("git-fetched-worktrees-"),
491 "fetched-origin",
492 );
493 yield* driver.createWorktree({
494 cwd,
495 path: worktreePath,
496 refName: resolvedBase.commitSha,
497 newRefName: "t3code/fetched-origin",
498 });
499
500 assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead);
501 assert.equal(
502 yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"),
503 null,
504 );
505 }),
506 );
507
445508 it.effect("pushes with upstream setup and skips when already up to date", () =>
446509 Effect.gen(function* () {
447510 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+34
......@@ -2188,6 +2188,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
21882188 );
21892189 });
21902190
2191 const fetchRemote: GitVcsDriver.GitVcsDriverShape["fetchRemote"] = Effect.fn("fetchRemote")(
2192 function* (input) {
2193 yield* executeGit(
2194 "GitVcsDriver.fetchRemote",
2195 input.cwd,
2196 ["fetch", "--quiet", input.remoteName],
2197 {
2198 env: STATUS_UPSTREAM_REFRESH_ENV,
2199 fallbackErrorMessage: `git fetch ${input.remoteName} failed`,
2200 },
2201 );
2202 },
2203 );
2204
2205 const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"] =
2206 Effect.fn("resolveRemoteTrackingCommit")(function* (input) {
2207 const remoteNames = yield* listRemoteNames(input.cwd);
2208 const parsedRemoteRef = parseRemoteRefWithRemoteNames(
2209 input.refName,
2210 remoteNames.toSorted((left, right) => right.length - left.length),
2211 );
2212 const remoteRefName =
2213 parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`;
2214 const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [
2215 "rev-parse",
2216 "--verify",
2217 `refs/remotes/${remoteRefName}^{commit}`,
2218 ]).pipe(Effect.map((stdout) => stdout.trim()));
2219
2220 return { commitSha, remoteRefName };
2221 });
2222
21912223 const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn(
21922224 "fetchRemoteBranch",
21932225 )(function* (input) {
......@@ -2413,6 +2445,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24132445 fetchPullRequestBranch,
24142446 ensureRemote,
24152447 resolvePrimaryRemoteName,
2448 fetchRemote,
2449 resolveRemoteTrackingCommit,
24162450 fetchRemoteBranch,
24172451 fetchRemoteTrackingBranch,
24182452 setBranchUpstream,
apps/server/src/ws.ts+14-1
......@@ -694,9 +694,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
694694 }
695695
696696 if (bootstrap?.prepareWorktree) {
697 let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch;
698 if (bootstrap.prepareWorktree.startFromOrigin) {
699 yield* gitWorkflow.fetchRemote({
700 cwd: bootstrap.prepareWorktree.projectCwd,
701 remoteName: "origin",
702 });
703 const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({
704 cwd: bootstrap.prepareWorktree.projectCwd,
705 refName: bootstrap.prepareWorktree.baseBranch,
706 fallbackRemoteName: "origin",
707 });
708 worktreeBaseRef = resolvedRemoteBase.commitSha;
709 }
697710 const worktree = yield* gitWorkflow.createWorktree({
698711 cwd: bootstrap.prepareWorktree.projectCwd,
699 refName: bootstrap.prepareWorktree.baseBranch,
712 refName: worktreeBaseRef,
700713 newRefName: bootstrap.prepareWorktree.branch,
701714 path: null,
702715 });
apps/web/src/components/BranchToolbar.tsx+6
......@@ -45,6 +45,8 @@ interface BranchToolbarProps {
4545 effectiveEnvModeOverride?: EnvMode;
4646 activeThreadBranchOverride?: string | null;
4747 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
48 startFromOrigin: boolean;
49 onStartFromOriginChange: (startFromOrigin: boolean) => void;
4850 envLocked: boolean;
4951 onCheckoutPullRequestRequest?: (reference: string) => void;
5052 onComposerFocusRequest?: () => void;
......@@ -196,6 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({
196198 effectiveEnvModeOverride,
197199 activeThreadBranchOverride,
198200 onActiveThreadBranchOverrideChange,
201 startFromOrigin,
202 onStartFromOriginChange,
199203 envLocked,
200204 onCheckoutPullRequestRequest,
201205 onComposerFocusRequest,
......@@ -279,6 +283,8 @@ export const BranchToolbar = memo(function BranchToolbar({
279283 {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
280284 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
281285 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
286 startFromOrigin={startFromOrigin}
287 onStartFromOriginChange={onStartFromOriginChange}
282288 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
283289 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
284290 />
apps/web/src/components/BranchToolbarBranchSelector.tsx+37-1
......@@ -5,11 +5,12 @@ import {
55} from "@t3tools/client-runtime/state/runtime";
66import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
77import { LegendList, type LegendListRef } from "@legendapp/list/react";
8import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react";
8import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
99import {
1010 useCallback,
1111 useDeferredValue,
1212 useEffect,
13 useId,
1314 useLayoutEffect,
1415 useMemo,
1516 useOptimistic,
......@@ -37,6 +38,8 @@ import {
3738 shouldIncludeBranchPickerItem,
3839} from "./BranchToolbar.logic";
3940import { Button } from "./ui/button";
41import { Switch } from "./ui/switch";
42import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
4043import {
4144 Combobox,
4245 ComboboxEmpty,
......@@ -58,6 +61,8 @@ interface BranchToolbarBranchSelectorProps {
5861 effectiveEnvModeOverride?: "local" | "worktree";
5962 activeThreadBranchOverride?: string | null;
6063 onActiveThreadBranchOverrideChange?: (refName: string | null) => void;
64 startFromOrigin: boolean;
65 onStartFromOriginChange: (startFromOrigin: boolean) => void;
6166 onCheckoutPullRequestRequest?: (reference: string) => void;
6267 onComposerFocusRequest?: () => void;
6368}
......@@ -90,9 +95,12 @@ export function BranchToolbarBranchSelector({
9095 effectiveEnvModeOverride,
9196 activeThreadBranchOverride,
9297 onActiveThreadBranchOverrideChange,
98 startFromOrigin,
99 onStartFromOriginChange,
93100 onCheckoutPullRequestRequest,
94101 onComposerFocusRequest,
95102}: BranchToolbarBranchSelectorProps) {
103 const startFromOriginSwitchId = useId();
96104 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
97105 const updateThreadMetadata = useAtomCommand(
98106 threadEnvironment.updateMetadata,
......@@ -674,6 +682,34 @@ export function BranchToolbarBranchSelector({
674682 />
675683 </ComboboxListVirtualized>
676684 </div>
685 {isSelectingWorktreeBase ? (
686 <Tooltip>
687 <TooltipTrigger
688 render={
689 <label
690 htmlFor={startFromOriginSwitchId}
691 className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs"
692 >
693 <span className="flex min-w-0 items-center gap-1.5 font-medium text-muted-foreground">
694 <RefreshCwIcon aria-hidden="true" className="size-3 shrink-0 opacity-70" />
695 <span className="truncate">Start from origin</span>
696 </span>
697 <Switch
698 id={startFromOriginSwitchId}
699 checked={startFromOrigin}
700 className="[--thumb-size:--spacing(3.5)]"
701 aria-label="Start worktree from origin"
702 onCheckedChange={(checked) => onStartFromOriginChange(Boolean(checked))}
703 />
704 </label>
705 }
706 />
707 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-tight">
708 Creates the worktree from the latest matching branch on origin instead of your local
709 branch.
710 </TooltipPopup>
711 </Tooltip>
712 ) : null}
677713 {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}
678714 </div>
679715 </ComboboxPopup>
apps/web/src/components/ChatView.tsx+35
......@@ -141,6 +141,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov
141141import { useSettings } from "../hooks/useSettings";
142142import { resolveAppModelSelectionForInstance } from "../modelSelection";
143143import { getTerminalFocusOwner } from "../lib/terminalFocus";
144import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
144145import {
145146 deriveLogicalProjectKeyFromSettings,
146147 selectProjectGroupingSettings,
......@@ -1129,6 +1130,10 @@ function ChatViewContent(props: ChatViewProps) {
11291130 const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] =
11301131 useState<DraftThreadEnvMode | null>(null);
11311132 const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState<string | null>();
1133 const [
1134 pendingServerThreadStartFromOriginByThreadId,
1135 setPendingServerThreadStartFromOriginByThreadId,
1136 ] = useState<Record<string, boolean>>({});
11321137 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
11331138 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
11341139 {},
......@@ -3330,6 +3335,12 @@ function ChatViewContent(props: ChatViewProps) {
33303335 canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined
33313336 ? pendingServerThreadBranch
33323337 : (activeThread?.branch ?? null);
3338 const startFromOrigin = isLocalDraftThread
3339 ? (draftThread?.startFromOrigin ?? false)
3340 : canOverrideServerThreadEnvMode
3341 ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ??
3342 settings.newWorktreesStartFromOrigin)
3343 : false;
33333344 const sendEnvMode = resolveSendEnvMode({
33343345 requestedEnvMode: envMode,
33353346 isGitRepo,
......@@ -3892,6 +3903,7 @@ function ChatViewContent(props: ChatViewProps) {
38923903 projectCwd: activeProject.workspaceRoot,
38933904 baseBranch: baseBranchForWorktree,
38943905 branch: buildTemporaryWorktreeBranchName(randomHex),
3906 ...(startFromOrigin ? { startFromOrigin: true } : {}),
38953907 },
38963908 runSetupScript: true,
38973909 }
......@@ -4577,6 +4589,10 @@ function ChatViewContent(props: ChatViewProps) {
45774589 if (isLocalDraftThread) {
45784590 setDraftThreadContext(composerDraftTarget, {
45794591 envMode: mode,
4592 startFromOrigin: resolveNewDraftStartFromOrigin({
4593 envMode: mode,
4594 newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin,
4595 }),
45804596 ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
45814597 });
45824598 }
......@@ -4587,12 +4603,29 @@ function ChatViewContent(props: ChatViewProps) {
45874603 composerDraftTarget,
45884604 draftThread?.worktreePath,
45894605 isLocalDraftThread,
4606 settings.newWorktreesStartFromOrigin,
45904607 setPendingServerThreadEnvMode,
45914608 scheduleComposerFocus,
45924609 setDraftThreadContext,
45934610 ],
45944611 );
45954612
4613 const onStartFromOriginChange = (nextStartFromOrigin: boolean) => {
4614 if (canOverrideServerThreadEnvMode && activeThread) {
4615 setPendingServerThreadStartFromOriginByThreadId((current) =>
4616 current[activeThread.id] === nextStartFromOrigin
4617 ? current
4618 : { ...current, [activeThread.id]: nextStartFromOrigin },
4619 );
4620 return;
4621 }
4622 if (isLocalDraftThread) {
4623 setDraftThreadContext(composerDraftTarget, {
4624 startFromOrigin: nextStartFromOrigin,
4625 });
4626 }
4627 };
4628
45964629 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
45974630 setExpandedImage(preview);
45984631 }, []);
......@@ -4926,6 +4959,8 @@ function ChatViewContent(props: ChatViewProps) {
49264959 threadId={activeThread.id}
49274960 {...(routeKind === "draft" && draftId ? { draftId } : {})}
49284961 onEnvModeChange={onEnvModeChange}
4962 startFromOrigin={startFromOrigin}
4963 onStartFromOriginChange={onStartFromOriginChange}
49294964 {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})}
49304965 {...(canOverrideServerThreadEnvMode
49314966 ? {
apps/web/src/components/Sidebar.logic.test.ts+3
......@@ -237,6 +237,7 @@ describe("resolveSidebarNewThreadSeedContext", () => {
237237 branch: "feature/draft",
238238 worktreePath: "/repo/.t3/worktrees/draft",
239239 envMode: "worktree",
240 startFromOrigin: true,
240241 },
241242 }),
242243 ).toEqual({
......@@ -278,12 +279,14 @@ describe("resolveSidebarNewThreadSeedContext", () => {
278279 branch: "feature/new-draft",
279280 worktreePath: "/repo/worktree",
280281 envMode: "worktree",
282 startFromOrigin: true,
281283 },
282284 }),
283285 ).toEqual({
284286 branch: "feature/new-draft",
285287 worktreePath: "/repo/worktree",
286288 envMode: "worktree",
289 startFromOrigin: true,
287290 });
288291 });
289292
apps/web/src/components/Sidebar.logic.ts+3
......@@ -189,11 +189,13 @@ export function resolveSidebarNewThreadSeedContext(input: {
189189 branch: string | null;
190190 worktreePath: string | null;
191191 envMode: SidebarNewThreadEnvMode;
192 startFromOrigin: boolean;
192193 } | null;
193194}): {
194195 branch?: string | null;
195196 worktreePath?: string | null;
196197 envMode: SidebarNewThreadEnvMode;
198 startFromOrigin?: boolean;
197199} {
198200 if (input.defaultEnvMode === "worktree") {
199201 return {
......@@ -206,6 +208,7 @@ export function resolveSidebarNewThreadSeedContext(input: {
206208 branch: input.activeDraftThread.branch,
207209 worktreePath: input.activeDraftThread.worktreePath,
208210 envMode: input.activeDraftThread.envMode,
211 startFromOrigin: input.activeDraftThread.startFromOrigin,
209212 };
210213 }
211214
apps/web/src/components/Sidebar.tsx+4
......@@ -1875,6 +1875,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18751875 branch: currentActiveDraftThread.branch,
18761876 worktreePath: currentActiveDraftThread.worktreePath,
18771877 envMode: currentActiveDraftThread.envMode,
1878 startFromOrigin: currentActiveDraftThread.startFromOrigin,
18781879 }
18791880 : null,
18801881 });
......@@ -1889,6 +1890,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18891890 ? { worktreePath: seedContext.worktreePath }
18901891 : {}),
18911892 envMode: seedContext.envMode,
1893 ...(seedContext.startFromOrigin !== undefined
1894 ? { startFromOrigin: seedContext.startFromOrigin }
1895 : {}),
18921896 }),
18931897 );
18941898 if (result._tag === "Failure") {
apps/web/src/components/settings/SettingsPanels.tsx+42-1
......@@ -408,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) {
408408 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
409409 ? ["New thread mode"]
410410 : []),
411 ...(settings.newWorktreesStartFromOrigin !==
412 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin
413 ? ["New worktrees start from origin"]
414 : []),
411415 ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
412416 ? ["Add project base directory"]
413417 : []),
......@@ -426,6 +430,7 @@ export function useSettingsRestore(onRestored?: () => void) {
426430 settings.confirmThreadDelete,
427431 settings.addProjectBaseDirectory,
428432 settings.defaultThreadEnvMode,
433 settings.newWorktreesStartFromOrigin,
429434 settings.diffIgnoreWhitespace,
430435 settings.diffWordWrap,
431436 settings.automaticGitFetchInterval,
......@@ -456,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {
456461 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
457462 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
458463 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
464 newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
459465 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
460466 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
461467 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
......@@ -692,12 +698,16 @@ export function GeneralSettingsPanel() {
692698 title="New threads"
693699 description="Pick the default workspace mode for newly created draft threads."
694700 resetAction={
695 settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? (
701 settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ||
702 settings.newWorktreesStartFromOrigin !==
703 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? (
696704 <SettingResetButton
697705 label="new threads"
698706 onClick={() =>
699707 updateSettings({
700708 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
709 newWorktreesStartFromOrigin:
710 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
701711 })
702712 }
703713 />
......@@ -729,6 +739,37 @@ export function GeneralSettingsPanel() {
729739 }
730740 />
731741
742 {settings.defaultThreadEnvMode === "worktree" ? (
743 <SettingsRow
744 className="bg-muted/20 sm:pl-9"
745 title="Start from origin"
746 description="Creates the worktree from the latest matching branch on origin instead of your local branch."
747 resetAction={
748 settings.newWorktreesStartFromOrigin !==
749 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? (
750 <SettingResetButton
751 label="new worktrees start from origin"
752 onClick={() =>
753 updateSettings({
754 newWorktreesStartFromOrigin:
755 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
756 })
757 }
758 />
759 ) : null
760 }
761 control={
762 <Switch
763 checked={settings.newWorktreesStartFromOrigin}
764 onCheckedChange={(checked) =>
765 updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) })
766 }
767 aria-label="Start new worktrees from origin by default"
768 />
769 }
770 />
771 ) : null}
772
732773 <SettingsRow
733774 title="Add project starts in"
734775 description='Leave empty to use "~/" when the Add Project browser opens.'
apps/web/src/composerDraftStore.test.ts+15
......@@ -1035,6 +1035,21 @@ describe("composerDraftStore project draft thread mapping", () => {
10351035 });
10361036 });
10371037
1038 it("stores the start-from-origin choice with the draft thread", () => {
1039 const store = useComposerDraftStore.getState();
1040 store.setProjectDraftThreadId(projectRef, draftId, {
1041 threadId,
1042 envMode: "worktree",
1043 startFromOrigin: true,
1044 });
1045
1046 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(true);
1047
1048 store.setDraftThreadContext(draftId, { startFromOrigin: false });
1049
1050 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(false);
1051 });
1052
10381053 it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {
10391054 const store = useComposerDraftStore.getState();
10401055 store.setProjectDraftThreadId(projectRef, draftId, {
apps/web/src/composerDraftStore.ts+27
......@@ -27,6 +27,7 @@ import {
2727} from "@t3tools/client-runtime/environment";
2828import * as Schema from "effect/Schema";
2929import * as Equal from "effect/Equal";
30import * as Effect from "effect/Effect";
3031import { DeepMutable } from "effect/Types";
3132import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
3233import { useMemo } from "react";
......@@ -214,6 +215,7 @@ const PersistedDraftThreadState = Schema.Struct({
214215 branch: Schema.NullOr(Schema.String),
215216 worktreePath: Schema.NullOr(Schema.String),
216217 envMode: DraftThreadEnvModeSchema,
218 startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
217219 promotedTo: Schema.optionalKey(
218220 Schema.NullOr(
219221 Schema.Struct({
......@@ -292,6 +294,7 @@ export interface DraftSessionState {
292294 branch: string | null;
293295 worktreePath: string | null;
294296 envMode: DraftThreadEnvMode;
297 startFromOrigin: boolean;
295298 promotedTo?: ScopedThreadRef | null;
296299}
297300
......@@ -353,6 +356,7 @@ interface ComposerDraftStoreState {
353356 worktreePath?: string | null;
354357 createdAt?: string;
355358 envMode?: DraftThreadEnvMode;
359 startFromOrigin?: boolean;
356360 runtimeMode?: RuntimeMode;
357361 interactionMode?: ProviderInteractionMode;
358362 },
......@@ -367,6 +371,7 @@ interface ComposerDraftStoreState {
367371 worktreePath?: string | null;
368372 createdAt?: string;
369373 envMode?: DraftThreadEnvMode;
374 startFromOrigin?: boolean;
370375 runtimeMode?: RuntimeMode;
371376 interactionMode?: ProviderInteractionMode;
372377 },
......@@ -380,6 +385,7 @@ interface ComposerDraftStoreState {
380385 projectRef?: ScopedProjectRef;
381386 createdAt?: string;
382387 envMode?: DraftThreadEnvMode;
388 startFromOrigin?: boolean;
383389 runtimeMode?: RuntimeMode;
384390 interactionMode?: ProviderInteractionMode;
385391 },
......@@ -1313,6 +1319,7 @@ function createDraftThreadState(
13131319 worktreePath?: string | null;
13141320 createdAt?: string;
13151321 envMode?: DraftThreadEnvMode;
1322 startFromOrigin?: boolean;
13161323 runtimeMode?: RuntimeMode;
13171324 interactionMode?: ProviderInteractionMode;
13181325 },
......@@ -1333,6 +1340,12 @@ function createDraftThreadState(
13331340 ? null
13341341 : (existingThread?.branch ?? null)
13351342 : (options.branch ?? null);
1343 const nextStartFromOrigin =
1344 options?.startFromOrigin === undefined
1345 ? projectChanged
1346 ? false
1347 : (existingThread?.startFromOrigin ?? false)
1348 : options.startFromOrigin;
13361349 return {
13371350 threadId,
13381351 environmentId: projectRef.environmentId,
......@@ -1351,6 +1364,7 @@ function createDraftThreadState(
13511364 : projectChanged
13521365 ? "local"
13531366 : (existingThread?.envMode ?? "local")),
1367 startFromOrigin: nextStartFromOrigin,
13541368 promotedTo: null,
13551369 };
13561370}
......@@ -1382,6 +1396,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea
13821396 left.branch === right.branch &&
13831397 left.worktreePath === right.worktreePath &&
13841398 left.envMode === right.envMode &&
1399 left.startFromOrigin === right.startFromOrigin &&
13851400 scopedThreadRefsEqual(left.promotedTo, right.promotedTo)
13861401 );
13871402}
......@@ -1476,6 +1491,7 @@ function normalizePersistedDraftThreads(
14761491 const createdAt = candidateDraftThread.createdAt;
14771492 const branch = candidateDraftThread.branch;
14781493 const worktreePath = candidateDraftThread.worktreePath;
1494 const startFromOrigin = candidateDraftThread.startFromOrigin === true;
14791495 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;
14801496 const promotedToCandidate = candidateDraftThread.promotedTo;
14811497 const promotedToRecord =
......@@ -1523,6 +1539,7 @@ function normalizePersistedDraftThreads(
15231539 branch: typeof branch === "string" ? branch : null,
15241540 worktreePath: normalizedWorktreePath,
15251541 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
1542 startFromOrigin,
15261543 promotedTo,
15271544 };
15281545 }
......@@ -1568,6 +1585,7 @@ function normalizePersistedDraftThreads(
15681585 branch: null,
15691586 worktreePath: null,
15701587 envMode: "local",
1588 startFromOrigin: false,
15711589 promotedTo: null,
15721590 };
15731591 } else if (
......@@ -2138,6 +2156,7 @@ function toHydratedDraftThreadState(
21382156 branch: persistedDraftThread.branch,
21392157 worktreePath: persistedDraftThread.worktreePath,
21402158 envMode: persistedDraftThread.envMode,
2159 startFromOrigin: persistedDraftThread.startFromOrigin,
21412160 promotedTo: persistedDraftThread.promotedTo
21422161 ? scopeThreadRef(
21432162 persistedDraftThread.promotedTo.environmentId as EnvironmentId,
......@@ -2323,6 +2342,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23232342 ? null
23242343 : existing.branch
23252344 : (options.branch ?? null);
2345 const nextStartFromOrigin =
2346 options.startFromOrigin === undefined
2347 ? projectChanged
2348 ? false
2349 : existing.startFromOrigin
2350 : options.startFromOrigin;
23262351 const nextDraftThread: DraftThreadState = {
23272352 threadId: existing.threadId,
23282353 environmentId: nextProjectRef.environmentId,
......@@ -2343,6 +2368,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23432368 : projectChanged
23442369 ? "local"
23452370 : (existing.envMode ?? "local")),
2371 startFromOrigin: nextStartFromOrigin,
23462372 promotedTo: existing.promotedTo ?? null,
23472373 };
23482374 const isUnchanged =
......@@ -2355,6 +2381,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23552381 nextDraftThread.branch === existing.branch &&
23562382 nextDraftThread.worktreePath === existing.worktreePath &&
23572383 nextDraftThread.envMode === existing.envMode &&
2384 nextDraftThread.startFromOrigin === existing.startFromOrigin &&
23582385 scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo);
23592386 if (isUnchanged) {
23602387 return state;
apps/web/src/hooks/useHandleNewThread.ts+30-4
......@@ -20,6 +20,7 @@ import {
2020 selectProjectGroupingSettings,
2121} from "../logicalProject";
2222import { readThreadShell, useProjects, useThread } from "../state/entities";
23import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
2324import { resolveThreadRouteTarget } from "../threadRoutes";
2425import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
2526import { useSettings } from "./useSettings";
......@@ -27,6 +28,9 @@ import { useSettings } from "./useSettings";
2728export function useNewThreadHandler() {
2829 const projects = useProjects();
2930 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
31 const newWorktreesStartFromOrigin = useSettings(
32 (settings) => settings.newWorktreesStartFromOrigin,
33 );
3034 const router = useRouter();
3135 const getCurrentRouteTarget = useCallback(() => {
3236 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
......@@ -40,6 +44,7 @@ export function useNewThreadHandler() {
4044 branch?: string | null;
4145 worktreePath?: string | null;
4246 envMode?: DraftThreadEnvMode;
47 startFromOrigin?: boolean;
4348 },
4449 ): Promise<void> => {
4550 const {
......@@ -62,6 +67,7 @@ export function useNewThreadHandler() {
6267 const hasBranchOption = options?.branch !== undefined;
6368 const hasWorktreePathOption = options?.worktreePath !== undefined;
6469 const hasEnvModeOption = options?.envMode !== undefined;
70 const hasStartFromOriginOption = options?.startFromOrigin !== undefined;
6571 const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);
6672 const storedDraftThreadRef = storedDraftThread
6773 ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId)
......@@ -80,11 +86,17 @@ export function useNewThreadHandler() {
8086 : null;
8187 if (reusableStoredDraftThread) {
8288 return (async () => {
83 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
89 if (
90 hasBranchOption ||
91 hasWorktreePathOption ||
92 hasEnvModeOption ||
93 hasStartFromOriginOption
94 ) {
8495 setDraftThreadContext(reusableStoredDraftThread.draftId, {
8596 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
8697 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
8798 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
99 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
88100 });
89101 }
90102 setLogicalProjectDraftThreadId(
......@@ -114,11 +126,17 @@ export function useNewThreadHandler() {
114126 latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&
115127 latestActiveDraftThread.promotedTo == null
116128 ) {
117 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
129 if (
130 hasBranchOption ||
131 hasWorktreePathOption ||
132 hasEnvModeOption ||
133 hasStartFromOriginOption
134 ) {
118135 setDraftThreadContext(currentRouteTarget.draftId, {
119136 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
120137 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
121138 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
139 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
122140 });
123141 }
124142 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {
......@@ -129,6 +147,7 @@ export function useNewThreadHandler() {
129147 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
130148 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
131149 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
150 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
132151 });
133152 return Promise.resolve();
134153 }
......@@ -136,13 +155,20 @@ export function useNewThreadHandler() {
136155 const draftId = newDraftId();
137156 const threadId = newThreadId();
138157 const createdAt = new Date().toISOString();
158 const initialEnvMode = options?.envMode ?? "local";
139159 return (async () => {
140160 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
141161 threadId,
142162 createdAt,
143163 branch: options?.branch ?? null,
144164 worktreePath: options?.worktreePath ?? null,
145 envMode: options?.envMode ?? "local",
165 envMode: initialEnvMode,
166 startFromOrigin:
167 options?.startFromOrigin ??
168 resolveNewDraftStartFromOrigin({
169 envMode: initialEnvMode,
170 newWorktreesStartFromOrigin,
171 }),
146172 runtimeMode: DEFAULT_RUNTIME_MODE,
147173 });
148174 applyStickyState(draftId);
......@@ -153,7 +179,7 @@ export function useNewThreadHandler() {
153179 });
154180 })();
155181 },
156 [getCurrentRouteTarget, projectGroupingSettings, router, projects],
182 [newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects],
157183 );
158184}
159185
apps/web/src/lib/chatThreadActions.test.ts+44
......@@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts";
33import { describe, expect, it, vi } from "vite-plus/test";
44import {
55 resolveThreadActionProjectRef,
6 resolveNewDraftStartFromOrigin,
67 startNewLocalThreadFromContext,
78 startNewThreadFromContext,
89 type ChatThreadActionContext,
......@@ -24,6 +25,21 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh
2425}
2526
2627describe("chatThreadActions", () => {
28 it("only applies the start-from-origin default to new worktree drafts", () => {
29 expect(
30 resolveNewDraftStartFromOrigin({
31 envMode: "worktree",
32 newWorktreesStartFromOrigin: true,
33 }),
34 ).toBe(true);
35 expect(
36 resolveNewDraftStartFromOrigin({
37 envMode: "local",
38 newWorktreesStartFromOrigin: true,
39 }),
40 ).toBe(false);
41 });
42
2743 it("prefers the active draft thread project when resolving thread actions", () => {
2844 const projectRef = resolveThreadActionProjectRef(
2945 createContext({
......@@ -33,6 +49,7 @@ describe("chatThreadActions", () => {
3349 branch: "feature/refactor",
3450 worktreePath: "/tmp/worktree",
3551 envMode: "worktree",
52 startFromOrigin: true,
3653 },
3754 }),
3855 );
......@@ -61,6 +78,7 @@ describe("chatThreadActions", () => {
6178 branch: "feature/refactor",
6279 worktreePath: "/tmp/worktree",
6380 envMode: "worktree",
81 startFromOrigin: true,
6482 },
6583 handleNewThread,
6684 }),
......@@ -71,6 +89,32 @@ describe("chatThreadActions", () => {
7189 branch: "feature/refactor",
7290 worktreePath: "/tmp/worktree",
7391 envMode: "worktree",
92 startFromOrigin: true,
93 });
94 });
95
96 it("preserves an explicitly disabled origin base in contextual thread options", async () => {
97 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
98
99 await startNewThreadFromContext(
100 createContext({
101 activeDraftThread: {
102 environmentId: ENVIRONMENT_ID,
103 projectId: PROJECT_ID,
104 branch: "feature/refactor",
105 worktreePath: "/tmp/worktree",
106 envMode: "worktree",
107 startFromOrigin: false,
108 },
109 handleNewThread,
110 }),
111 );
112
113 expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), {
114 branch: "feature/refactor",
115 worktreePath: "/tmp/worktree",
116 envMode: "worktree",
117 startFromOrigin: false,
74118 });
75119 });
76120
apps/web/src/lib/chatThreadActions.ts+12
......@@ -11,6 +11,7 @@ interface ThreadContextLike {
1111
1212interface DraftThreadContextLike extends ThreadContextLike {
1313 envMode: DraftThreadEnvMode;
14 startFromOrigin: boolean;
1415}
1516
1617interface NewThreadHandler {
......@@ -20,6 +21,7 @@ interface NewThreadHandler {
2021 branch?: string | null;
2122 worktreePath?: string | null;
2223 envMode?: DraftThreadEnvMode;
24 startFromOrigin?: boolean;
2325 },
2426 ): Promise<void>;
2527}
......@@ -34,6 +36,13 @@ export interface ChatThreadActionContext {
3436 readonly handleNewThread: NewThreadHandler;
3537}
3638
39export function resolveNewDraftStartFromOrigin(input: {
40 envMode: DraftThreadEnvMode;
41 newWorktreesStartFromOrigin: boolean;
42}): boolean {
43 return input.envMode === "worktree" && input.newWorktreesStartFromOrigin;
44}
45
3746export function resolveThreadActionProjectRef(
3847 context: ChatThreadActionContext,
3948): ScopedProjectRef | null {
......@@ -57,6 +66,9 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre
5766 envMode:
5867 context.activeDraftThread?.envMode ??
5968 (context.activeThread?.worktreePath ? "worktree" : "local"),
69 ...(context.activeDraftThread
70 ? { startFromOrigin: context.activeDraftThread.startFromOrigin }
71 : {}),
6072 };
6173}
6274
packages/contracts/src/orchestration.test.ts+2
......@@ -277,6 +277,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>
277277 projectCwd: "/tmp/workspace",
278278 baseBranch: "main",
279279 branch: "t3code/example",
280 startFromOrigin: true,
280281 },
281282 runSetupScript: true,
282283 },
......@@ -284,6 +285,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>
284285 });
285286 assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1");
286287 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main");
288 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true);
287289 assert.strictEqual(parsed.bootstrap?.runSetupScript, true);
288290 }),
289291);
packages/contracts/src/orchestration.ts+1
......@@ -565,6 +565,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({
565565 projectCwd: TrimmedNonEmptyString,
566566 baseBranch: TrimmedNonEmptyString,
567567 branch: Schema.optional(TrimmedNonEmptyString),
568 startFromOrigin: Schema.optional(Schema.Boolean),
568569});
569570
570571const ThreadTurnStartBootstrap = Schema.Struct({
packages/contracts/src/settings.test.ts+12
......@@ -64,6 +64,18 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
6464 });
6565});
6666
67describe("ServerSettings worktree defaults", () => {
68 it("defaults start-from-origin off for legacy configs", () => {
69 expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false);
70 });
71
72 it("accepts start-from-origin updates", () => {
73 expect(
74 decodeServerSettingsPatch({ newWorktreesStartFromOrigin: true }).newWorktreesStartFromOrigin,
75 ).toBe(true);
76 });
77});
78
6779describe("ServerSettingsPatch.providerInstances", () => {
6880 it("treats providerInstances as an optional whole-map replacement", () => {
6981 const patch = decodeServerSettingsPatch({});
packages/contracts/src/settings.ts+4
......@@ -373,6 +373,9 @@ export const ServerSettings = Schema.Struct({
373373 defaultThreadEnvMode: ThreadEnvMode.pipe(
374374 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
375375 ),
376 newWorktreesStartFromOrigin: Schema.Boolean.pipe(
377 Schema.withDecodingDefault(Effect.succeed(false)),
378 ),
376379 addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
377380 textGenerationModelSelection: ModelSelection.pipe(
378381 Schema.withDecodingDefault(
......@@ -481,6 +484,7 @@ export const ServerSettingsPatch = Schema.Struct({
481484 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
482485 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),
483486 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
487 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
484488 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
485489 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
486490 observability: Schema.optionalKey(