| author | |
| committer | |
| log | 52a24c890265967aa31fa03c4339210022ac403d |
| tree | d54695bedaa8b84fc7832394b34e51f1da0665ec |
| parent | 30034ecedc467ea2e00075f8cd2abd11ef8dd990 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 163 | 163 | ||
| 164 | export interface GitFetchRemoteInput { | ||
| 165 | cwd: string; | ||
| 166 | remoteName: string; | ||
| 167 | } | ||
| 168 | |||
| 169 | export interface GitResolveRemoteTrackingCommitInput { | ||
| 170 | cwd: string; | ||
| 171 | refName: string; | ||
| 172 | fallbackRemoteName: string; | ||
| 173 | } | ||
| 174 | |||
| 175 | export interface GitResolveRemoteTrackingCommitResult { | ||
| 176 | commitSha: string; | ||
| 177 | remoteRefName: string; | ||
| 178 | } | ||
| 179 | |||
| 164 | export interface GitSetBranchUpstreamInput { | 180 | export 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 | }); |
| 443 | 443 | ||
| 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 | }); |
| 2190 | 2190 | ||
| 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 | } |
| 695 | 695 | ||
| 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"; |
| 6 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; | 6 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; |
| 7 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | 7 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 8 | import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; | 8 | import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; |
| 9 | import { | 9 | import { |
| 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"; |
| 39 | import { Button } from "./ui/button"; | 40 | import { Button } from "./ui/button"; |
| 41 | import { Switch } from "./ui/switch"; | ||
| 42 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | ||
| 40 | import { | 43 | import { |
| 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 |
| 141 | import { useSettings } from "../hooks/useSettings"; | 141 | import { useSettings } from "../hooks/useSettings"; |
| 142 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 142 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 143 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; | 143 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| 144 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; | ||
| 144 | import { | 145 | import { |
| 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 !== undefined | 3335 | canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined |
| 3331 | ? pendingServerThreadBranch | 3336 | ? 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 | ); |
| 4595 | 4612 | ||
| 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 | {...(canOverrideServerThreadEnvMode | 4965 | {...(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 | }); |
| 289 | 292 |
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 | } |
| 211 | 214 |
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.defaultThreadEnvMode | 408 | ...(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.addProjectBaseDirectory | 415 | ...(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 | <SettingResetButton | 704 | <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 | /> |
| 731 | 741 | ||
| 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 | <SettingsRow | 773 | <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 | }); |
| 1037 | 1037 | ||
| 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"; |
| 28 | import * as Schema from "effect/Schema"; | 28 | import * as Schema from "effect/Schema"; |
| 29 | import * as Equal from "effect/Equal"; | 29 | import * as Equal from "effect/Equal"; |
| 30 | import * as Effect from "effect/Effect"; | ||
| 30 | import { DeepMutable } from "effect/Types"; | 31 | import { DeepMutable } from "effect/Types"; |
| 31 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 32 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 32 | import { useMemo } from "react"; | 33 | import { 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 | } |
| 297 | 300 | ||
| ... | @@ -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 | ? null | 1340 | ? 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 | : projectChanged | 1364 | : 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.promotedTo | 2160 | 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 | ? null | 2342 | ? null |
| 2324 | : existing.branch | 2343 | : 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 | : projectChanged | 2368 | : 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"; |
| 22 | import { readThreadShell, useProjects, useThread } from "../state/entities"; | 22 | import { readThreadShell, useProjects, useThread } from "../state/entities"; |
| 23 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; | ||
| 23 | import { resolveThreadRouteTarget } from "../threadRoutes"; | 24 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 24 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | 25 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 25 | import { useSettings } from "./useSettings"; | 26 | import { useSettings } from "./useSettings"; |
| ... | @@ -27,6 +28,9 @@ import { useSettings } from "./useSettings"; | ... | @@ -27,6 +28,9 @@ import { useSettings } from "./useSettings"; |
| 27 | export function useNewThreadHandler() { | 28 | export 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 = storedDraftThread | 72 | 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 == null | 127 | 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 | } |
| 159 | 185 |
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"; |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | 3 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 4 | import { | 4 | import { |
| 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 | } |
| 25 | 26 | ||
| 26 | describe("chatThreadActions", () => { | 27 | describe("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 | }); |
| 76 | 120 |
apps/web/src/lib/chatThreadActions.ts+12| ... | @@ -11,6 +11,7 @@ interface ThreadContextLike { | ... | @@ -11,6 +11,7 @@ interface ThreadContextLike { |
| 11 | 11 | ||
| 12 | interface DraftThreadContextLike extends ThreadContextLike { | 12 | interface DraftThreadContextLike extends ThreadContextLike { |
| 13 | envMode: DraftThreadEnvMode; | 13 | envMode: DraftThreadEnvMode; |
| 14 | startFromOrigin: boolean; | ||
| 14 | } | 15 | } |
| 15 | 16 | ||
| 16 | interface NewThreadHandler { | 17 | interface 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 | } |
| 36 | 38 | ||
| 39 | export function resolveNewDraftStartFromOrigin(input: { | ||
| 40 | envMode: DraftThreadEnvMode; | ||
| 41 | newWorktreesStartFromOrigin: boolean; | ||
| 42 | }): boolean { | ||
| 43 | return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; | ||
| 44 | } | ||
| 45 | |||
| 37 | export function resolveThreadActionProjectRef( | 46 | export 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 | } |
| 62 | 74 |
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 | }); |
| 569 | 570 | ||
| 570 | const ThreadTurnStartBootstrap = Schema.Struct({ | 571 | const 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 | }); |
| 66 | 66 | ||
| 67 | describe("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 | |||
| 67 | describe("ServerSettingsPatch.providerInstances", () => { | 79 | describe("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( |