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 {...@@ -61,6 +61,18 @@ export interface GitWorkflowServiceShape {
61 readonly createWorktree: (61 readonly createWorktree: (
62 input: VcsCreateWorktreeInput,62 input: VcsCreateWorktreeInput,
63 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;63 ) => 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 >;
64 readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;76 readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
65 readonly createRef: (77 readonly createRef: (
66 input: VcsCreateRefInput,78 input: VcsCreateRefInput,
...@@ -295,6 +307,14 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {...@@ -295,6 +307,14 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
295 ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(307 ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
296 Effect.andThen(git.createWorktree(input)),308 Effect.andThen(git.createWorktree(input)),
297 ),309 ),
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 ),
298 removeWorktree: (input) =>318 removeWorktree: (input) =>
299 ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(319 ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
300 Effect.andThen(git.removeWorktree(input)),320 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) => {...@@ -5918,6 +5918,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5918 () =>5918 () =>
5919 Effect.gen(function* () {5919 Effect.gen(function* () {
5920 const dispatchedCommands: Array<OrchestrationCommand> = [];5920 const dispatchedCommands: Array<OrchestrationCommand> = [];
5921 const bootstrapGitOperations: string[] = [];
5921 const refreshStatus = vi.fn((_: string) =>5922 const refreshStatus = vi.fn((_: string) =>
5922 Effect.succeed({5923 Effect.succeed({
5923 isRepo: true,5924 isRepo: true,
...@@ -5936,13 +5937,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5936,13 +5937,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5936 pr: null,5937 pr: null,
5937 }),5938 }),
5938 );5939 );
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 );
5939 const createWorktree = vi.fn(5957 const createWorktree = vi.fn(
5940 (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) =>5958 (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) =>
5941 Effect.succeed({5959 Effect.sync(() => {
5942 worktree: {5960 bootstrapGitOperations.push("create-worktree");
5943 refName: "t3code/bootstrap-refName",5961 return {
5944 path: "/tmp/bootstrap-worktree",5962 worktree: {
5945 },5963 refName: "t3code/bootstrap-refName",
5964 path: "/tmp/bootstrap-worktree",
5965 },
5966 };
5946 }),5967 }),
5947 );5968 );
5948 const runForThread = vi.fn(5969 const runForThread = vi.fn(
...@@ -5959,6 +5980,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5959,6 +5980,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5959 yield* buildAppUnderTest({5980 yield* buildAppUnderTest({
5960 layers: {5981 layers: {
5961 gitVcsDriver: {5982 gitVcsDriver: {
5983 fetchRemote,
5984 resolveRemoteTrackingCommit,
5962 createWorktree,5985 createWorktree,
5963 },5986 },
5964 vcsStatusBroadcaster: {5987 vcsStatusBroadcaster: {
...@@ -6010,6 +6033,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6010,6 +6033,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6010 projectCwd: "/tmp/project",6033 projectCwd: "/tmp/project",
6011 baseBranch: "main",6034 baseBranch: "main",
6012 branch: "t3code/bootstrap-refName",6035 branch: "t3code/bootstrap-refName",
6036 startFromOrigin: true,
6013 },6037 },
6014 runSetupScript: true,6038 runSetupScript: true,
6015 },6039 },
...@@ -6031,10 +6055,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6031,10 +6055,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6031 );6055 );
6032 assert.deepEqual(createWorktree.mock.calls[0]?.[0], {6056 assert.deepEqual(createWorktree.mock.calls[0]?.[0], {
6033 cwd: "/tmp/project",6057 cwd: "/tmp/project",
6034 refName: "main",6058 refName: fetchedOriginCommit,
6035 newRefName: "t3code/bootstrap-refName",6059 newRefName: "t3code/bootstrap-refName",
6036 path: null,6060 path: null,
6037 });6061 });
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 ]);
6038 assert.deepEqual(runForThread.mock.calls[0]?.[0], {6076 assert.deepEqual(runForThread.mock.calls[0]?.[0], {
6039 threadId: ThreadId.make("thread-bootstrap"),6077 threadId: ThreadId.make("thread-bootstrap"),
6040 projectId: defaultProjectId,6078 projectId: defaultProjectId,
apps/server/src/vcs/GitVcsDriver.ts+20
...@@ -161,6 +161,22 @@ export interface GitFetchRemoteTrackingBranchInput {...@@ -161,6 +161,22 @@ export interface GitFetchRemoteTrackingBranchInput {
161 remoteBranch: string;161 remoteBranch: string;
162}162}
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
164export interface GitSetBranchUpstreamInput {180export interface GitSetBranchUpstreamInput {
165 cwd: string;181 cwd: string;
166 branch: string;182 branch: string;
...@@ -217,6 +233,10 @@ export interface GitVcsDriverShape {...@@ -217,6 +233,10 @@ export interface GitVcsDriverShape {
217 ) => Effect.Effect<void, GitCommandError>;233 ) => Effect.Effect<void, GitCommandError>;
218 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;234 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
219 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;235 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>;
220 readonly fetchRemoteBranch: (240 readonly fetchRemoteBranch: (
221 input: GitFetchRemoteBranchInput,241 input: GitFetchRemoteBranchInput,
222 ) => Effect.Effect<void, GitCommandError>;242 ) => Effect.Effect<void, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.test.ts+63
...@@ -442,6 +442,69 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -442,6 +442,69 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
442 });442 });
443443
444 describe("remote operations", () => {444 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
445 it.effect("pushes with upstream setup and skips when already up to date", () =>508 it.effect("pushes with upstream setup and skips when already up to date", () =>
446 Effect.gen(function* () {509 Effect.gen(function* () {
447 const cwd = yield* makeTmpDir();510 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+34
...@@ -2188,6 +2188,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2188,6 +2188,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2188 );2188 );
2189 });2189 });
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
2191 const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn(2223 const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn(
2192 "fetchRemoteBranch",2224 "fetchRemoteBranch",
2193 )(function* (input) {2225 )(function* (input) {
...@@ -2413,6 +2445,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2413,6 +2445,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2413 fetchPullRequestBranch,2445 fetchPullRequestBranch,
2414 ensureRemote,2446 ensureRemote,
2415 resolvePrimaryRemoteName,2447 resolvePrimaryRemoteName,
2448 fetchRemote,
2449 resolveRemoteTrackingCommit,
2416 fetchRemoteBranch,2450 fetchRemoteBranch,
2417 fetchRemoteTrackingBranch,2451 fetchRemoteTrackingBranch,
2418 setBranchUpstream,2452 setBranchUpstream,
apps/server/src/ws.ts+14-1
...@@ -694,9 +694,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -694,9 +694,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
694 }694 }
695695
696 if (bootstrap?.prepareWorktree) {696 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 }
697 const worktree = yield* gitWorkflow.createWorktree({710 const worktree = yield* gitWorkflow.createWorktree({
698 cwd: bootstrap.prepareWorktree.projectCwd,711 cwd: bootstrap.prepareWorktree.projectCwd,
699 refName: bootstrap.prepareWorktree.baseBranch,712 refName: worktreeBaseRef,
700 newRefName: bootstrap.prepareWorktree.branch,713 newRefName: bootstrap.prepareWorktree.branch,
701 path: null,714 path: null,
702 });715 });
apps/web/src/components/BranchToolbar.tsx+6
...@@ -45,6 +45,8 @@ interface BranchToolbarProps {...@@ -45,6 +45,8 @@ interface BranchToolbarProps {
45 effectiveEnvModeOverride?: EnvMode;45 effectiveEnvModeOverride?: EnvMode;
46 activeThreadBranchOverride?: string | null;46 activeThreadBranchOverride?: string | null;
47 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;47 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
48 startFromOrigin: boolean;
49 onStartFromOriginChange: (startFromOrigin: boolean) => void;
48 envLocked: boolean;50 envLocked: boolean;
49 onCheckoutPullRequestRequest?: (reference: string) => void;51 onCheckoutPullRequestRequest?: (reference: string) => void;
50 onComposerFocusRequest?: () => void;52 onComposerFocusRequest?: () => void;
...@@ -196,6 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -196,6 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({
196 effectiveEnvModeOverride,198 effectiveEnvModeOverride,
197 activeThreadBranchOverride,199 activeThreadBranchOverride,
198 onActiveThreadBranchOverrideChange,200 onActiveThreadBranchOverrideChange,
201 startFromOrigin,
202 onStartFromOriginChange,
199 envLocked,203 envLocked,
200 onCheckoutPullRequestRequest,204 onCheckoutPullRequestRequest,
201 onComposerFocusRequest,205 onComposerFocusRequest,
...@@ -279,6 +283,8 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -279,6 +283,8 @@ export const BranchToolbar = memo(function BranchToolbar({
279 {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}283 {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
280 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}284 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
281 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}285 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
286 startFromOrigin={startFromOrigin}
287 onStartFromOriginChange={onStartFromOriginChange}
282 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}288 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
283 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}289 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
284 />290 />
apps/web/src/components/BranchToolbarBranchSelector.tsx+37-1
...@@ -5,11 +5,12 @@ import {...@@ -5,11 +5,12 @@ import {
5} from "@t3tools/client-runtime/state/runtime";5} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";6import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
7import { LegendList, type LegendListRef } from "@legendapp/list/react";7import { LegendList, type LegendListRef } from "@legendapp/list/react";
8import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react";8import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
9import {9import {
10 useCallback,10 useCallback,
11 useDeferredValue,11 useDeferredValue,
12 useEffect,12 useEffect,
13 useId,
13 useLayoutEffect,14 useLayoutEffect,
14 useMemo,15 useMemo,
15 useOptimistic,16 useOptimistic,
...@@ -37,6 +38,8 @@ import {...@@ -37,6 +38,8 @@ import {
37 shouldIncludeBranchPickerItem,38 shouldIncludeBranchPickerItem,
38} from "./BranchToolbar.logic";39} from "./BranchToolbar.logic";
39import { Button } from "./ui/button";40import { Button } from "./ui/button";
41import { Switch } from "./ui/switch";
42import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
40import {43import {
41 Combobox,44 Combobox,
42 ComboboxEmpty,45 ComboboxEmpty,
...@@ -58,6 +61,8 @@ interface BranchToolbarBranchSelectorProps {...@@ -58,6 +61,8 @@ interface BranchToolbarBranchSelectorProps {
58 effectiveEnvModeOverride?: "local" | "worktree";61 effectiveEnvModeOverride?: "local" | "worktree";
59 activeThreadBranchOverride?: string | null;62 activeThreadBranchOverride?: string | null;
60 onActiveThreadBranchOverrideChange?: (refName: string | null) => void;63 onActiveThreadBranchOverrideChange?: (refName: string | null) => void;
64 startFromOrigin: boolean;
65 onStartFromOriginChange: (startFromOrigin: boolean) => void;
61 onCheckoutPullRequestRequest?: (reference: string) => void;66 onCheckoutPullRequestRequest?: (reference: string) => void;
62 onComposerFocusRequest?: () => void;67 onComposerFocusRequest?: () => void;
63}68}
...@@ -90,9 +95,12 @@ export function BranchToolbarBranchSelector({...@@ -90,9 +95,12 @@ export function BranchToolbarBranchSelector({
90 effectiveEnvModeOverride,95 effectiveEnvModeOverride,
91 activeThreadBranchOverride,96 activeThreadBranchOverride,
92 onActiveThreadBranchOverrideChange,97 onActiveThreadBranchOverrideChange,
98 startFromOrigin,
99 onStartFromOriginChange,
93 onCheckoutPullRequestRequest,100 onCheckoutPullRequestRequest,
94 onComposerFocusRequest,101 onComposerFocusRequest,
95}: BranchToolbarBranchSelectorProps) {102}: BranchToolbarBranchSelectorProps) {
103 const startFromOriginSwitchId = useId();
96 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");104 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
97 const updateThreadMetadata = useAtomCommand(105 const updateThreadMetadata = useAtomCommand(
98 threadEnvironment.updateMetadata,106 threadEnvironment.updateMetadata,
...@@ -674,6 +682,34 @@ export function BranchToolbarBranchSelector({...@@ -674,6 +682,34 @@ export function BranchToolbarBranchSelector({
674 />682 />
675 </ComboboxListVirtualized>683 </ComboboxListVirtualized>
676 </div>684 </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}
677 {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}713 {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}
678 </div>714 </div>
679 </ComboboxPopup>715 </ComboboxPopup>
apps/web/src/components/ChatView.tsx+35
...@@ -141,6 +141,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov...@@ -141,6 +141,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov
141import { useSettings } from "../hooks/useSettings";141import { useSettings } from "../hooks/useSettings";
142import { resolveAppModelSelectionForInstance } from "../modelSelection";142import { resolveAppModelSelectionForInstance } from "../modelSelection";
143import { getTerminalFocusOwner } from "../lib/terminalFocus";143import { getTerminalFocusOwner } from "../lib/terminalFocus";
144import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
144import {145import {
145 deriveLogicalProjectKeyFromSettings,146 deriveLogicalProjectKeyFromSettings,
146 selectProjectGroupingSettings,147 selectProjectGroupingSettings,
...@@ -1129,6 +1130,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1129,6 +1130,10 @@ function ChatViewContent(props: ChatViewProps) {
1129 const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] =1130 const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] =
1130 useState<DraftThreadEnvMode | null>(null);1131 useState<DraftThreadEnvMode | null>(null);
1131 const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState<string | null>();1132 const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState<string | null>();
1133 const [
1134 pendingServerThreadStartFromOriginByThreadId,
1135 setPendingServerThreadStartFromOriginByThreadId,
1136 ] = useState<Record<string, boolean>>({});
1132 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(1137 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
1133 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,1138 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
1134 {},1139 {},
...@@ -3330,6 +3335,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3330,6 +3335,12 @@ function ChatViewContent(props: ChatViewProps) {
3330 canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined3335 canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined
3331 ? pendingServerThreadBranch3336 ? pendingServerThreadBranch
3332 : (activeThread?.branch ?? null);3337 : (activeThread?.branch ?? null);
3338 const startFromOrigin = isLocalDraftThread
3339 ? (draftThread?.startFromOrigin ?? false)
3340 : canOverrideServerThreadEnvMode
3341 ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ??
3342 settings.newWorktreesStartFromOrigin)
3343 : false;
3333 const sendEnvMode = resolveSendEnvMode({3344 const sendEnvMode = resolveSendEnvMode({
3334 requestedEnvMode: envMode,3345 requestedEnvMode: envMode,
3335 isGitRepo,3346 isGitRepo,
...@@ -3892,6 +3903,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3892,6 +3903,7 @@ function ChatViewContent(props: ChatViewProps) {
3892 projectCwd: activeProject.workspaceRoot,3903 projectCwd: activeProject.workspaceRoot,
3893 baseBranch: baseBranchForWorktree,3904 baseBranch: baseBranchForWorktree,
3894 branch: buildTemporaryWorktreeBranchName(randomHex),3905 branch: buildTemporaryWorktreeBranchName(randomHex),
3906 ...(startFromOrigin ? { startFromOrigin: true } : {}),
3895 },3907 },
3896 runSetupScript: true,3908 runSetupScript: true,
3897 }3909 }
...@@ -4577,6 +4589,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4577,6 +4589,10 @@ function ChatViewContent(props: ChatViewProps) {
4577 if (isLocalDraftThread) {4589 if (isLocalDraftThread) {
4578 setDraftThreadContext(composerDraftTarget, {4590 setDraftThreadContext(composerDraftTarget, {
4579 envMode: mode,4591 envMode: mode,
4592 startFromOrigin: resolveNewDraftStartFromOrigin({
4593 envMode: mode,
4594 newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin,
4595 }),
4580 ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),4596 ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
4581 });4597 });
4582 }4598 }
...@@ -4587,12 +4603,29 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4587,12 +4603,29 @@ function ChatViewContent(props: ChatViewProps) {
4587 composerDraftTarget,4603 composerDraftTarget,
4588 draftThread?.worktreePath,4604 draftThread?.worktreePath,
4589 isLocalDraftThread,4605 isLocalDraftThread,
4606 settings.newWorktreesStartFromOrigin,
4590 setPendingServerThreadEnvMode,4607 setPendingServerThreadEnvMode,
4591 scheduleComposerFocus,4608 scheduleComposerFocus,
4592 setDraftThreadContext,4609 setDraftThreadContext,
4593 ],4610 ],
4594 );4611 );
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
4596 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {4629 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
4597 setExpandedImage(preview);4630 setExpandedImage(preview);
4598 }, []);4631 }, []);
...@@ -4926,6 +4959,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4926,6 +4959,8 @@ function ChatViewContent(props: ChatViewProps) {
4926 threadId={activeThread.id}4959 threadId={activeThread.id}
4927 {...(routeKind === "draft" && draftId ? { draftId } : {})}4960 {...(routeKind === "draft" && draftId ? { draftId } : {})}
4928 onEnvModeChange={onEnvModeChange}4961 onEnvModeChange={onEnvModeChange}
4962 startFromOrigin={startFromOrigin}
4963 onStartFromOriginChange={onStartFromOriginChange}
4929 {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})}4964 {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})}
4930 {...(canOverrideServerThreadEnvMode4965 {...(canOverrideServerThreadEnvMode
4931 ? {4966 ? {
apps/web/src/components/Sidebar.logic.test.ts+3
...@@ -237,6 +237,7 @@ describe("resolveSidebarNewThreadSeedContext", () => {...@@ -237,6 +237,7 @@ describe("resolveSidebarNewThreadSeedContext", () => {
237 branch: "feature/draft",237 branch: "feature/draft",
238 worktreePath: "/repo/.t3/worktrees/draft",238 worktreePath: "/repo/.t3/worktrees/draft",
239 envMode: "worktree",239 envMode: "worktree",
240 startFromOrigin: true,
240 },241 },
241 }),242 }),
242 ).toEqual({243 ).toEqual({
...@@ -278,12 +279,14 @@ describe("resolveSidebarNewThreadSeedContext", () => {...@@ -278,12 +279,14 @@ describe("resolveSidebarNewThreadSeedContext", () => {
278 branch: "feature/new-draft",279 branch: "feature/new-draft",
279 worktreePath: "/repo/worktree",280 worktreePath: "/repo/worktree",
280 envMode: "worktree",281 envMode: "worktree",
282 startFromOrigin: true,
281 },283 },
282 }),284 }),
283 ).toEqual({285 ).toEqual({
284 branch: "feature/new-draft",286 branch: "feature/new-draft",
285 worktreePath: "/repo/worktree",287 worktreePath: "/repo/worktree",
286 envMode: "worktree",288 envMode: "worktree",
289 startFromOrigin: true,
287 });290 });
288 });291 });
289292
apps/web/src/components/Sidebar.logic.ts+3
...@@ -189,11 +189,13 @@ export function resolveSidebarNewThreadSeedContext(input: {...@@ -189,11 +189,13 @@ export function resolveSidebarNewThreadSeedContext(input: {
189 branch: string | null;189 branch: string | null;
190 worktreePath: string | null;190 worktreePath: string | null;
191 envMode: SidebarNewThreadEnvMode;191 envMode: SidebarNewThreadEnvMode;
192 startFromOrigin: boolean;
192 } | null;193 } | null;
193}): {194}): {
194 branch?: string | null;195 branch?: string | null;
195 worktreePath?: string | null;196 worktreePath?: string | null;
196 envMode: SidebarNewThreadEnvMode;197 envMode: SidebarNewThreadEnvMode;
198 startFromOrigin?: boolean;
197} {199} {
198 if (input.defaultEnvMode === "worktree") {200 if (input.defaultEnvMode === "worktree") {
199 return {201 return {
...@@ -206,6 +208,7 @@ export function resolveSidebarNewThreadSeedContext(input: {...@@ -206,6 +208,7 @@ export function resolveSidebarNewThreadSeedContext(input: {
206 branch: input.activeDraftThread.branch,208 branch: input.activeDraftThread.branch,
207 worktreePath: input.activeDraftThread.worktreePath,209 worktreePath: input.activeDraftThread.worktreePath,
208 envMode: input.activeDraftThread.envMode,210 envMode: input.activeDraftThread.envMode,
211 startFromOrigin: input.activeDraftThread.startFromOrigin,
209 };212 };
210 }213 }
211214
apps/web/src/components/Sidebar.tsx+4
...@@ -1875,6 +1875,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1875,6 +1875,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1875 branch: currentActiveDraftThread.branch,1875 branch: currentActiveDraftThread.branch,
1876 worktreePath: currentActiveDraftThread.worktreePath,1876 worktreePath: currentActiveDraftThread.worktreePath,
1877 envMode: currentActiveDraftThread.envMode,1877 envMode: currentActiveDraftThread.envMode,
1878 startFromOrigin: currentActiveDraftThread.startFromOrigin,
1878 }1879 }
1879 : null,1880 : null,
1880 });1881 });
...@@ -1889,6 +1890,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1889,6 +1890,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1889 ? { worktreePath: seedContext.worktreePath }1890 ? { worktreePath: seedContext.worktreePath }
1890 : {}),1891 : {}),
1891 envMode: seedContext.envMode,1892 envMode: seedContext.envMode,
1893 ...(seedContext.startFromOrigin !== undefined
1894 ? { startFromOrigin: seedContext.startFromOrigin }
1895 : {}),
1892 }),1896 }),
1893 );1897 );
1894 if (result._tag === "Failure") {1898 if (result._tag === "Failure") {
apps/web/src/components/settings/SettingsPanels.tsx+42-1
...@@ -408,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -408,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) {
408 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode408 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
409 ? ["New thread mode"]409 ? ["New thread mode"]
410 : []),410 : []),
411 ...(settings.newWorktreesStartFromOrigin !==
412 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin
413 ? ["New worktrees start from origin"]
414 : []),
411 ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory415 ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
412 ? ["Add project base directory"]416 ? ["Add project base directory"]
413 : []),417 : []),
...@@ -426,6 +430,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -426,6 +430,7 @@ export function useSettingsRestore(onRestored?: () => void) {
426 settings.confirmThreadDelete,430 settings.confirmThreadDelete,
427 settings.addProjectBaseDirectory,431 settings.addProjectBaseDirectory,
428 settings.defaultThreadEnvMode,432 settings.defaultThreadEnvMode,
433 settings.newWorktreesStartFromOrigin,
429 settings.diffIgnoreWhitespace,434 settings.diffIgnoreWhitespace,
430 settings.diffWordWrap,435 settings.diffWordWrap,
431 settings.automaticGitFetchInterval,436 settings.automaticGitFetchInterval,
...@@ -456,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -456,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {
456 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,461 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
457 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,462 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
458 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,463 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
464 newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
459 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,465 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
460 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,466 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
461 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,467 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
...@@ -692,12 +698,16 @@ export function GeneralSettingsPanel() {...@@ -692,12 +698,16 @@ export function GeneralSettingsPanel() {
692 title="New threads"698 title="New threads"
693 description="Pick the default workspace mode for newly created draft threads."699 description="Pick the default workspace mode for newly created draft threads."
694 resetAction={700 resetAction={
695 settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? (701 settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ||
702 settings.newWorktreesStartFromOrigin !==
703 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? (
696 <SettingResetButton704 <SettingResetButton
697 label="new threads"705 label="new threads"
698 onClick={() =>706 onClick={() =>
699 updateSettings({707 updateSettings({
700 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,708 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
709 newWorktreesStartFromOrigin:
710 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
701 })711 })
702 }712 }
703 />713 />
...@@ -729,6 +739,37 @@ export function GeneralSettingsPanel() {...@@ -729,6 +739,37 @@ export function GeneralSettingsPanel() {
729 }739 }
730 />740 />
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
732 <SettingsRow773 <SettingsRow
733 title="Add project starts in"774 title="Add project starts in"
734 description='Leave empty to use "~/" when the Add Project browser opens.'775 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", () => {...@@ -1035,6 +1035,21 @@ describe("composerDraftStore project draft thread mapping", () => {
1035 });1035 });
1036 });1036 });
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
1038 it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {1053 it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {
1039 const store = useComposerDraftStore.getState();1054 const store = useComposerDraftStore.getState();
1040 store.setProjectDraftThreadId(projectRef, draftId, {1055 store.setProjectDraftThreadId(projectRef, draftId, {
apps/web/src/composerDraftStore.ts+27
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27} from "@t3tools/client-runtime/environment";27} from "@t3tools/client-runtime/environment";
28import * as Schema from "effect/Schema";28import * as Schema from "effect/Schema";
29import * as Equal from "effect/Equal";29import * as Equal from "effect/Equal";
30import * as Effect from "effect/Effect";
30import { DeepMutable } from "effect/Types";31import { DeepMutable } from "effect/Types";
31import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";32import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
32import { useMemo } from "react";33import { useMemo } from "react";
...@@ -214,6 +215,7 @@ const PersistedDraftThreadState = Schema.Struct({...@@ -214,6 +215,7 @@ const PersistedDraftThreadState = Schema.Struct({
214 branch: Schema.NullOr(Schema.String),215 branch: Schema.NullOr(Schema.String),
215 worktreePath: Schema.NullOr(Schema.String),216 worktreePath: Schema.NullOr(Schema.String),
216 envMode: DraftThreadEnvModeSchema,217 envMode: DraftThreadEnvModeSchema,
218 startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
217 promotedTo: Schema.optionalKey(219 promotedTo: Schema.optionalKey(
218 Schema.NullOr(220 Schema.NullOr(
219 Schema.Struct({221 Schema.Struct({
...@@ -292,6 +294,7 @@ export interface DraftSessionState {...@@ -292,6 +294,7 @@ export interface DraftSessionState {
292 branch: string | null;294 branch: string | null;
293 worktreePath: string | null;295 worktreePath: string | null;
294 envMode: DraftThreadEnvMode;296 envMode: DraftThreadEnvMode;
297 startFromOrigin: boolean;
295 promotedTo?: ScopedThreadRef | null;298 promotedTo?: ScopedThreadRef | null;
296}299}
297300
...@@ -353,6 +356,7 @@ interface ComposerDraftStoreState {...@@ -353,6 +356,7 @@ interface ComposerDraftStoreState {
353 worktreePath?: string | null;356 worktreePath?: string | null;
354 createdAt?: string;357 createdAt?: string;
355 envMode?: DraftThreadEnvMode;358 envMode?: DraftThreadEnvMode;
359 startFromOrigin?: boolean;
356 runtimeMode?: RuntimeMode;360 runtimeMode?: RuntimeMode;
357 interactionMode?: ProviderInteractionMode;361 interactionMode?: ProviderInteractionMode;
358 },362 },
...@@ -367,6 +371,7 @@ interface ComposerDraftStoreState {...@@ -367,6 +371,7 @@ interface ComposerDraftStoreState {
367 worktreePath?: string | null;371 worktreePath?: string | null;
368 createdAt?: string;372 createdAt?: string;
369 envMode?: DraftThreadEnvMode;373 envMode?: DraftThreadEnvMode;
374 startFromOrigin?: boolean;
370 runtimeMode?: RuntimeMode;375 runtimeMode?: RuntimeMode;
371 interactionMode?: ProviderInteractionMode;376 interactionMode?: ProviderInteractionMode;
372 },377 },
...@@ -380,6 +385,7 @@ interface ComposerDraftStoreState {...@@ -380,6 +385,7 @@ interface ComposerDraftStoreState {
380 projectRef?: ScopedProjectRef;385 projectRef?: ScopedProjectRef;
381 createdAt?: string;386 createdAt?: string;
382 envMode?: DraftThreadEnvMode;387 envMode?: DraftThreadEnvMode;
388 startFromOrigin?: boolean;
383 runtimeMode?: RuntimeMode;389 runtimeMode?: RuntimeMode;
384 interactionMode?: ProviderInteractionMode;390 interactionMode?: ProviderInteractionMode;
385 },391 },
...@@ -1313,6 +1319,7 @@ function createDraftThreadState(...@@ -1313,6 +1319,7 @@ function createDraftThreadState(
1313 worktreePath?: string | null;1319 worktreePath?: string | null;
1314 createdAt?: string;1320 createdAt?: string;
1315 envMode?: DraftThreadEnvMode;1321 envMode?: DraftThreadEnvMode;
1322 startFromOrigin?: boolean;
1316 runtimeMode?: RuntimeMode;1323 runtimeMode?: RuntimeMode;
1317 interactionMode?: ProviderInteractionMode;1324 interactionMode?: ProviderInteractionMode;
1318 },1325 },
...@@ -1333,6 +1340,12 @@ function createDraftThreadState(...@@ -1333,6 +1340,12 @@ function createDraftThreadState(
1333 ? null1340 ? null
1334 : (existingThread?.branch ?? null)1341 : (existingThread?.branch ?? null)
1335 : (options.branch ?? null);1342 : (options.branch ?? null);
1343 const nextStartFromOrigin =
1344 options?.startFromOrigin === undefined
1345 ? projectChanged
1346 ? false
1347 : (existingThread?.startFromOrigin ?? false)
1348 : options.startFromOrigin;
1336 return {1349 return {
1337 threadId,1350 threadId,
1338 environmentId: projectRef.environmentId,1351 environmentId: projectRef.environmentId,
...@@ -1351,6 +1364,7 @@ function createDraftThreadState(...@@ -1351,6 +1364,7 @@ function createDraftThreadState(
1351 : projectChanged1364 : projectChanged
1352 ? "local"1365 ? "local"
1353 : (existingThread?.envMode ?? "local")),1366 : (existingThread?.envMode ?? "local")),
1367 startFromOrigin: nextStartFromOrigin,
1354 promotedTo: null,1368 promotedTo: null,
1355 };1369 };
1356}1370}
...@@ -1382,6 +1396,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea...@@ -1382,6 +1396,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea
1382 left.branch === right.branch &&1396 left.branch === right.branch &&
1383 left.worktreePath === right.worktreePath &&1397 left.worktreePath === right.worktreePath &&
1384 left.envMode === right.envMode &&1398 left.envMode === right.envMode &&
1399 left.startFromOrigin === right.startFromOrigin &&
1385 scopedThreadRefsEqual(left.promotedTo, right.promotedTo)1400 scopedThreadRefsEqual(left.promotedTo, right.promotedTo)
1386 );1401 );
1387}1402}
...@@ -1476,6 +1491,7 @@ function normalizePersistedDraftThreads(...@@ -1476,6 +1491,7 @@ function normalizePersistedDraftThreads(
1476 const createdAt = candidateDraftThread.createdAt;1491 const createdAt = candidateDraftThread.createdAt;
1477 const branch = candidateDraftThread.branch;1492 const branch = candidateDraftThread.branch;
1478 const worktreePath = candidateDraftThread.worktreePath;1493 const worktreePath = candidateDraftThread.worktreePath;
1494 const startFromOrigin = candidateDraftThread.startFromOrigin === true;
1479 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;1495 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;
1480 const promotedToCandidate = candidateDraftThread.promotedTo;1496 const promotedToCandidate = candidateDraftThread.promotedTo;
1481 const promotedToRecord =1497 const promotedToRecord =
...@@ -1523,6 +1539,7 @@ function normalizePersistedDraftThreads(...@@ -1523,6 +1539,7 @@ function normalizePersistedDraftThreads(
1523 branch: typeof branch === "string" ? branch : null,1539 branch: typeof branch === "string" ? branch : null,
1524 worktreePath: normalizedWorktreePath,1540 worktreePath: normalizedWorktreePath,
1525 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),1541 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
1542 startFromOrigin,
1526 promotedTo,1543 promotedTo,
1527 };1544 };
1528 }1545 }
...@@ -1568,6 +1585,7 @@ function normalizePersistedDraftThreads(...@@ -1568,6 +1585,7 @@ function normalizePersistedDraftThreads(
1568 branch: null,1585 branch: null,
1569 worktreePath: null,1586 worktreePath: null,
1570 envMode: "local",1587 envMode: "local",
1588 startFromOrigin: false,
1571 promotedTo: null,1589 promotedTo: null,
1572 };1590 };
1573 } else if (1591 } else if (
...@@ -2138,6 +2156,7 @@ function toHydratedDraftThreadState(...@@ -2138,6 +2156,7 @@ function toHydratedDraftThreadState(
2138 branch: persistedDraftThread.branch,2156 branch: persistedDraftThread.branch,
2139 worktreePath: persistedDraftThread.worktreePath,2157 worktreePath: persistedDraftThread.worktreePath,
2140 envMode: persistedDraftThread.envMode,2158 envMode: persistedDraftThread.envMode,
2159 startFromOrigin: persistedDraftThread.startFromOrigin,
2141 promotedTo: persistedDraftThread.promotedTo2160 promotedTo: persistedDraftThread.promotedTo
2142 ? scopeThreadRef(2161 ? scopeThreadRef(
2143 persistedDraftThread.promotedTo.environmentId as EnvironmentId,2162 persistedDraftThread.promotedTo.environmentId as EnvironmentId,
...@@ -2323,6 +2342,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2323,6 +2342,12 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2323 ? null2342 ? null
2324 : existing.branch2343 : existing.branch
2325 : (options.branch ?? null);2344 : (options.branch ?? null);
2345 const nextStartFromOrigin =
2346 options.startFromOrigin === undefined
2347 ? projectChanged
2348 ? false
2349 : existing.startFromOrigin
2350 : options.startFromOrigin;
2326 const nextDraftThread: DraftThreadState = {2351 const nextDraftThread: DraftThreadState = {
2327 threadId: existing.threadId,2352 threadId: existing.threadId,
2328 environmentId: nextProjectRef.environmentId,2353 environmentId: nextProjectRef.environmentId,
...@@ -2343,6 +2368,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2343,6 +2368,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2343 : projectChanged2368 : projectChanged
2344 ? "local"2369 ? "local"
2345 : (existing.envMode ?? "local")),2370 : (existing.envMode ?? "local")),
2371 startFromOrigin: nextStartFromOrigin,
2346 promotedTo: existing.promotedTo ?? null,2372 promotedTo: existing.promotedTo ?? null,
2347 };2373 };
2348 const isUnchanged =2374 const isUnchanged =
...@@ -2355,6 +2381,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2355,6 +2381,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2355 nextDraftThread.branch === existing.branch &&2381 nextDraftThread.branch === existing.branch &&
2356 nextDraftThread.worktreePath === existing.worktreePath &&2382 nextDraftThread.worktreePath === existing.worktreePath &&
2357 nextDraftThread.envMode === existing.envMode &&2383 nextDraftThread.envMode === existing.envMode &&
2384 nextDraftThread.startFromOrigin === existing.startFromOrigin &&
2358 scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo);2385 scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo);
2359 if (isUnchanged) {2386 if (isUnchanged) {
2360 return state;2387 return state;
apps/web/src/hooks/useHandleNewThread.ts+30-4
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 selectProjectGroupingSettings,20 selectProjectGroupingSettings,
21} from "../logicalProject";21} from "../logicalProject";
22import { readThreadShell, useProjects, useThread } from "../state/entities";22import { readThreadShell, useProjects, useThread } from "../state/entities";
23import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
23import { resolveThreadRouteTarget } from "../threadRoutes";24import { resolveThreadRouteTarget } from "../threadRoutes";
24import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";25import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
25import { useSettings } from "./useSettings";26import { useSettings } from "./useSettings";
...@@ -27,6 +28,9 @@ import { useSettings } from "./useSettings";...@@ -27,6 +28,9 @@ import { useSettings } from "./useSettings";
27export function useNewThreadHandler() {28export function useNewThreadHandler() {
28 const projects = useProjects();29 const projects = useProjects();
29 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);30 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
31 const newWorktreesStartFromOrigin = useSettings(
32 (settings) => settings.newWorktreesStartFromOrigin,
33 );
30 const router = useRouter();34 const router = useRouter();
31 const getCurrentRouteTarget = useCallback(() => {35 const getCurrentRouteTarget = useCallback(() => {
32 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};36 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
...@@ -40,6 +44,7 @@ export function useNewThreadHandler() {...@@ -40,6 +44,7 @@ export function useNewThreadHandler() {
40 branch?: string | null;44 branch?: string | null;
41 worktreePath?: string | null;45 worktreePath?: string | null;
42 envMode?: DraftThreadEnvMode;46 envMode?: DraftThreadEnvMode;
47 startFromOrigin?: boolean;
43 },48 },
44 ): Promise<void> => {49 ): Promise<void> => {
45 const {50 const {
...@@ -62,6 +67,7 @@ export function useNewThreadHandler() {...@@ -62,6 +67,7 @@ export function useNewThreadHandler() {
62 const hasBranchOption = options?.branch !== undefined;67 const hasBranchOption = options?.branch !== undefined;
63 const hasWorktreePathOption = options?.worktreePath !== undefined;68 const hasWorktreePathOption = options?.worktreePath !== undefined;
64 const hasEnvModeOption = options?.envMode !== undefined;69 const hasEnvModeOption = options?.envMode !== undefined;
70 const hasStartFromOriginOption = options?.startFromOrigin !== undefined;
65 const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);71 const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);
66 const storedDraftThreadRef = storedDraftThread72 const storedDraftThreadRef = storedDraftThread
67 ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId)73 ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId)
...@@ -80,11 +86,17 @@ export function useNewThreadHandler() {...@@ -80,11 +86,17 @@ export function useNewThreadHandler() {
80 : null;86 : null;
81 if (reusableStoredDraftThread) {87 if (reusableStoredDraftThread) {
82 return (async () => {88 return (async () => {
83 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {89 if (
90 hasBranchOption ||
91 hasWorktreePathOption ||
92 hasEnvModeOption ||
93 hasStartFromOriginOption
94 ) {
84 setDraftThreadContext(reusableStoredDraftThread.draftId, {95 setDraftThreadContext(reusableStoredDraftThread.draftId, {
85 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),96 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
86 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),97 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
87 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),98 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
99 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
88 });100 });
89 }101 }
90 setLogicalProjectDraftThreadId(102 setLogicalProjectDraftThreadId(
...@@ -114,11 +126,17 @@ export function useNewThreadHandler() {...@@ -114,11 +126,17 @@ export function useNewThreadHandler() {
114 latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&126 latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&
115 latestActiveDraftThread.promotedTo == null127 latestActiveDraftThread.promotedTo == null
116 ) {128 ) {
117 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {129 if (
130 hasBranchOption ||
131 hasWorktreePathOption ||
132 hasEnvModeOption ||
133 hasStartFromOriginOption
134 ) {
118 setDraftThreadContext(currentRouteTarget.draftId, {135 setDraftThreadContext(currentRouteTarget.draftId, {
119 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),136 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
120 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),137 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
121 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),138 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
139 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
122 });140 });
123 }141 }
124 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {142 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {
...@@ -129,6 +147,7 @@ export function useNewThreadHandler() {...@@ -129,6 +147,7 @@ export function useNewThreadHandler() {
129 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),147 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
130 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),148 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
131 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),149 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
150 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
132 });151 });
133 return Promise.resolve();152 return Promise.resolve();
134 }153 }
...@@ -136,13 +155,20 @@ export function useNewThreadHandler() {...@@ -136,13 +155,20 @@ export function useNewThreadHandler() {
136 const draftId = newDraftId();155 const draftId = newDraftId();
137 const threadId = newThreadId();156 const threadId = newThreadId();
138 const createdAt = new Date().toISOString();157 const createdAt = new Date().toISOString();
158 const initialEnvMode = options?.envMode ?? "local";
139 return (async () => {159 return (async () => {
140 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {160 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
141 threadId,161 threadId,
142 createdAt,162 createdAt,
143 branch: options?.branch ?? null,163 branch: options?.branch ?? null,
144 worktreePath: options?.worktreePath ?? null,164 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 }),
146 runtimeMode: DEFAULT_RUNTIME_MODE,172 runtimeMode: DEFAULT_RUNTIME_MODE,
147 });173 });
148 applyStickyState(draftId);174 applyStickyState(draftId);
...@@ -153,7 +179,7 @@ export function useNewThreadHandler() {...@@ -153,7 +179,7 @@ export function useNewThreadHandler() {
153 });179 });
154 })();180 })();
155 },181 },
156 [getCurrentRouteTarget, projectGroupingSettings, router, projects],182 [newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects],
157 );183 );
158}184}
159185
apps/web/src/lib/chatThreadActions.test.ts+44
...@@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts";...@@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts";
3import { describe, expect, it, vi } from "vite-plus/test";3import { describe, expect, it, vi } from "vite-plus/test";
4import {4import {
5 resolveThreadActionProjectRef,5 resolveThreadActionProjectRef,
6 resolveNewDraftStartFromOrigin,
6 startNewLocalThreadFromContext,7 startNewLocalThreadFromContext,
7 startNewThreadFromContext,8 startNewThreadFromContext,
8 type ChatThreadActionContext,9 type ChatThreadActionContext,
...@@ -24,6 +25,21 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh...@@ -24,6 +25,21 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh
24}25}
2526
26describe("chatThreadActions", () => {27describe("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
27 it("prefers the active draft thread project when resolving thread actions", () => {43 it("prefers the active draft thread project when resolving thread actions", () => {
28 const projectRef = resolveThreadActionProjectRef(44 const projectRef = resolveThreadActionProjectRef(
29 createContext({45 createContext({
...@@ -33,6 +49,7 @@ describe("chatThreadActions", () => {...@@ -33,6 +49,7 @@ describe("chatThreadActions", () => {
33 branch: "feature/refactor",49 branch: "feature/refactor",
34 worktreePath: "/tmp/worktree",50 worktreePath: "/tmp/worktree",
35 envMode: "worktree",51 envMode: "worktree",
52 startFromOrigin: true,
36 },53 },
37 }),54 }),
38 );55 );
...@@ -61,6 +78,7 @@ describe("chatThreadActions", () => {...@@ -61,6 +78,7 @@ describe("chatThreadActions", () => {
61 branch: "feature/refactor",78 branch: "feature/refactor",
62 worktreePath: "/tmp/worktree",79 worktreePath: "/tmp/worktree",
63 envMode: "worktree",80 envMode: "worktree",
81 startFromOrigin: true,
64 },82 },
65 handleNewThread,83 handleNewThread,
66 }),84 }),
...@@ -71,6 +89,32 @@ describe("chatThreadActions", () => {...@@ -71,6 +89,32 @@ describe("chatThreadActions", () => {
71 branch: "feature/refactor",89 branch: "feature/refactor",
72 worktreePath: "/tmp/worktree",90 worktreePath: "/tmp/worktree",
73 envMode: "worktree",91 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,
74 });118 });
75 });119 });
76120
apps/web/src/lib/chatThreadActions.ts+12
...@@ -11,6 +11,7 @@ interface ThreadContextLike {...@@ -11,6 +11,7 @@ interface ThreadContextLike {
1111
12interface DraftThreadContextLike extends ThreadContextLike {12interface DraftThreadContextLike extends ThreadContextLike {
13 envMode: DraftThreadEnvMode;13 envMode: DraftThreadEnvMode;
14 startFromOrigin: boolean;
14}15}
1516
16interface NewThreadHandler {17interface NewThreadHandler {
...@@ -20,6 +21,7 @@ interface NewThreadHandler {...@@ -20,6 +21,7 @@ interface NewThreadHandler {
20 branch?: string | null;21 branch?: string | null;
21 worktreePath?: string | null;22 worktreePath?: string | null;
22 envMode?: DraftThreadEnvMode;23 envMode?: DraftThreadEnvMode;
24 startFromOrigin?: boolean;
23 },25 },
24 ): Promise<void>;26 ): Promise<void>;
25}27}
...@@ -34,6 +36,13 @@ export interface ChatThreadActionContext {...@@ -34,6 +36,13 @@ export interface ChatThreadActionContext {
34 readonly handleNewThread: NewThreadHandler;36 readonly handleNewThread: NewThreadHandler;
35}37}
3638
39export function resolveNewDraftStartFromOrigin(input: {
40 envMode: DraftThreadEnvMode;
41 newWorktreesStartFromOrigin: boolean;
42}): boolean {
43 return input.envMode === "worktree" && input.newWorktreesStartFromOrigin;
44}
45
37export function resolveThreadActionProjectRef(46export function resolveThreadActionProjectRef(
38 context: ChatThreadActionContext,47 context: ChatThreadActionContext,
39): ScopedProjectRef | null {48): ScopedProjectRef | null {
...@@ -57,6 +66,9 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre...@@ -57,6 +66,9 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre
57 envMode:66 envMode:
58 context.activeDraftThread?.envMode ??67 context.activeDraftThread?.envMode ??
59 (context.activeThread?.worktreePath ? "worktree" : "local"),68 (context.activeThread?.worktreePath ? "worktree" : "local"),
69 ...(context.activeDraftThread
70 ? { startFromOrigin: context.activeDraftThread.startFromOrigin }
71 : {}),
60 };72 };
61}73}
6274
packages/contracts/src/orchestration.test.ts+2
...@@ -277,6 +277,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>...@@ -277,6 +277,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>
277 projectCwd: "/tmp/workspace",277 projectCwd: "/tmp/workspace",
278 baseBranch: "main",278 baseBranch: "main",
279 branch: "t3code/example",279 branch: "t3code/example",
280 startFromOrigin: true,
280 },281 },
281 runSetupScript: true,282 runSetupScript: true,
282 },283 },
...@@ -284,6 +285,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>...@@ -284,6 +285,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>
284 });285 });
285 assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1");286 assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1");
286 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main");287 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main");
288 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true);
287 assert.strictEqual(parsed.bootstrap?.runSetupScript, true);289 assert.strictEqual(parsed.bootstrap?.runSetupScript, true);
288 }),290 }),
289);291);
packages/contracts/src/orchestration.ts+1
...@@ -565,6 +565,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({...@@ -565,6 +565,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({
565 projectCwd: TrimmedNonEmptyString,565 projectCwd: TrimmedNonEmptyString,
566 baseBranch: TrimmedNonEmptyString,566 baseBranch: TrimmedNonEmptyString,
567 branch: Schema.optional(TrimmedNonEmptyString),567 branch: Schema.optional(TrimmedNonEmptyString),
568 startFromOrigin: Schema.optional(Schema.Boolean),
568});569});
569570
570const ThreadTurnStartBootstrap = Schema.Struct({571const ThreadTurnStartBootstrap = Schema.Struct({
packages/contracts/src/settings.test.ts+12
...@@ -64,6 +64,18 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {...@@ -64,6 +64,18 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
64 });64 });
65});65});
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
67describe("ServerSettingsPatch.providerInstances", () => {79describe("ServerSettingsPatch.providerInstances", () => {
68 it("treats providerInstances as an optional whole-map replacement", () => {80 it("treats providerInstances as an optional whole-map replacement", () => {
69 const patch = decodeServerSettingsPatch({});81 const patch = decodeServerSettingsPatch({});
packages/contracts/src/settings.ts+4
...@@ -373,6 +373,9 @@ export const ServerSettings = Schema.Struct({...@@ -373,6 +373,9 @@ export const ServerSettings = Schema.Struct({
373 defaultThreadEnvMode: ThreadEnvMode.pipe(373 defaultThreadEnvMode: ThreadEnvMode.pipe(
374 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),374 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
375 ),375 ),
376 newWorktreesStartFromOrigin: Schema.Boolean.pipe(
377 Schema.withDecodingDefault(Effect.succeed(false)),
378 ),
376 addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),379 addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
377 textGenerationModelSelection: ModelSelection.pipe(380 textGenerationModelSelection: ModelSelection.pipe(
378 Schema.withDecodingDefault(381 Schema.withDecodingDefault(
...@@ -481,6 +484,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -481,6 +484,7 @@ export const ServerSettingsPatch = Schema.Struct({
481 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),484 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
482 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),485 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),
483 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),486 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
487 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
484 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),488 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
485 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),489 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
486 observability: Schema.optionalKey(490 observability: Schema.optionalKey(