diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index c5ec1b5ba..a17a408fa 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -114,6 +114,7 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function deviceNames: true, draftGoalSetup: true, pullRequestSwitchesByNumber: true, + worktreePickup: true, }, } satisfies Omit; diff --git a/apps/server/src/guide/topics.ts b/apps/server/src/guide/topics.ts index a91162d56..d8d6b5064 100644 --- a/apps/server/src/guide/topics.ts +++ b/apps/server/src/guide/topics.ts @@ -30,13 +30,26 @@ export const GUIDE_TOPICS: ReadonlyArray = [ { id: "threads-and-worktrees", title: "Threads and worktrees", - keywords: ["thread", "worktree", "branch", "checkout", "setup script", "wrap", "archive"], + keywords: [ + "thread", + "worktree", + "existing worktree", + "pick up", + "import", + "branch", + "checkout", + "setup script", + "wrap", + "archive", + ], body: `A thread is one conversation with an agent in a project. Threads are listed in the sidebar; one that needs you, finished or failed says so there. In a narrow window the sidebar floats over the thread instead of sitting beside it: the sidebar button at the top left, or Cmd+B (Ctrl+B on Windows and Linux), opens it, and picking a thread or clicking the thread puts it away. The sliders button beside the project picker at the top of the sidebar groups the list by project or computer and sorts it: recent activity, newest, oldest, title, or a custom order. In the custom order you drag rows to arrange them, or use Move up and Move down in a thread's menu, and only you move a row; new threads go to the bottom, and the same button can reset the order. Pinned threads stay above a thin line in every sort. When the list is grouped, drag a group's header to move the whole group, in any sort; its menu has Move up, Move down and Use automatic order. These orders are kept per device, so a phone has its own. A new thread starts either in the project folder as it is, or in a worktree of its own: a separate git checkout on a new branch, so its changes stay apart until you merge them. You pick per thread in the composer; the default is {place:threads.startIn}, and new worktrees branch from {place:threads.worktreesFrom}. A project's setup script runs once in each new worktree. +A repository can already have worktrees that Threadlines did not make (git worktree add, or another app). Right after such a repository is added as a project, the new-thread screen lists them under the message box with a checkbox each, and "Add N threads" makes a thread for every ticked one, named from its branch; nothing runs until you send a message there. After that the screen shows only a count beside the recent threads ("4 worktrees"); opening it lists them with Work here, which points the thread you are about to start at that worktree, Add as threads…, and Hide for this project. The Workspace picker under the message box lists the same worktrees under Existing worktree, and the command palette has Add existing worktrees as threads…, which also brings a hidden count back. A thread in one of these is a guest: no setup script runs, the branch is not renamed, deleting the thread never offers to delete the folder, and undoing a turn only undoes that thread's own edits. Worktrees a thread already works in are left out. The computer's server needs to be on a version that has this; with an older one none of it shows. + The agent and model a new thread starts with is {place:threads.agent}. You can switch model or agent at any time from the model button in the composer, which also holds the agent's effort. Beside it sit Build or Plan (one click switches, as does Shift+Tab in the message box) and the access level; on a narrow window they show just their icons, and on phones both live in the model button's panel instead. While an agent works, the arrow beside the send button picks how a message goes: Steer now adds it to the reply in progress, Send when done holds it for the next one, and the choice sticks. Enter sends that way; Cmd+Enter (Ctrl+Enter on Windows and Linux) sends that one message the other way. diff --git a/apps/server/src/orchestration/childThreadDecisions.ts b/apps/server/src/orchestration/childThreadDecisions.ts index f095a6d8b..6cdd48359 100644 --- a/apps/server/src/orchestration/childThreadDecisions.ts +++ b/apps/server/src/orchestration/childThreadDecisions.ts @@ -125,7 +125,9 @@ export function claimsServerLineage(fields: { fields.remoteOrigin !== undefined || fields.scheduledRun !== undefined || fields.outsideOrigin !== undefined || - fields.worktreeOrigin !== undefined + // Picking up a worktree the repository already had is the user's to say. + (fields.worktreeOrigin !== undefined && + (fields.worktreeOrigin as { readonly kind?: unknown } | null)?.kind !== "existing") ); } diff --git a/apps/server/src/orchestration/decider.childThreads.test.ts b/apps/server/src/orchestration/decider.childThreads.test.ts index ba551c5bb..21debc5d0 100644 --- a/apps/server/src/orchestration/decider.childThreads.test.ts +++ b/apps/server/src/orchestration/decider.childThreads.test.ts @@ -1200,6 +1200,63 @@ describe("decider child threads in another thread's checkout", () => { }); }); +describe("decider threads in a worktree the repository already had", () => { + const pickUp = ( + overrides: Partial> = {}, + ): Extract => ({ + type: "thread.create", + commandId: CommandId.make("pick-up"), + threadId: CHILD, + projectId, + title: "Checkout retry", + modelSelection: model, + runtimeMode: "auto-accept-edits", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: "fix/checkout-retry", + worktreePath: "/code/shop-checkout-retry", + worktreeOrigin: { kind: "existing" }, + createdAt: at(1), + ...overrides, + }); + + it("makes the thread a guest there with no thread starting it, and never in the project folder", async () => { + const picked = await run(readModel([]), pickUp()); + expect(threadIn(picked.model, CHILD)).toMatchObject({ + worktreePath: "/code/shop-checkout-retry", + branch: "fix/checkout-retry", + worktreeOrigin: { kind: "existing" }, + parentThreadId: null, + }); + // The project folder is nobody's to be a guest in. + expect(await refused(readModel([]), pickUp({ worktreePath: null }))).toBe(true); + expect(await refused(readModel([]), pickUp({ worktreePath: "/repo" }))).toBe(true); + }); + + it("makes a saved thread a guest when the user moves it into one, until it leaves", async () => { + const select = ( + worktreePath: string | null, + ): Extract => ({ + type: "thread.checkout.select", + commandId: CommandId.make(`select-${worktreePath ?? "root"}`), + threadId: PARENT, + branch: worktreePath === null ? "main" : "fix/checkout-retry", + worktreePath, + existingWorktree: true, + }); + const moved = await run(readModel([thread(PARENT)]), select("/code/shop-checkout-retry")); + expect(threadIn(moved.model, PARENT)).toMatchObject({ + worktreePath: "/code/shop-checkout-retry", + worktreeOrigin: { kind: "existing" }, + }); + // Back in the project folder it is nobody's guest, whatever the client says. + const back = await run(moved.model, select(null)); + expect(threadIn(back.model, PARENT)).toMatchObject({ + worktreePath: null, + worktreeOrigin: null, + }); + }); +}); + describe("decider child threads on a linked computer", () => { it("keeps a start for a linked computer on the parent only, waiting to be sent", async () => { const { started, model: family } = await familyWithRemoteChild(); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 27eedb00a..f72e8e43a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1099,16 +1099,20 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: "A thread an outside agent starts has no other origin.", }); } - // Another thread's worktree is lent only to a thread an agent started - // here (ThreadWorktreeOrigin), and only as a worktree. + // A worktree the thread does not own (ThreadWorktreeOrigin) is a + // worktree, never the project folder; another thread's is lent only to + // a thread an agent started here. const createdWorktreePath = normalizeWorktreePath( command.worktreePath, project.workspaceRoot, ); - if ( - command.worktreeOrigin !== undefined && - (parentThreadId === undefined || createdWorktreePath === null) - ) { + if (command.worktreeOrigin !== undefined && createdWorktreePath === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "A thread is a guest only in a worktree, never in the project folder.", + }); + } + if (command.worktreeOrigin?.kind === "thread" && parentThreadId === undefined) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: "Only a thread another thread started can work in that thread's worktree.", @@ -2703,10 +2707,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: thread.projectId, }); const occurredAt = yield* nowIso; + const selectedWorktreePath = normalizeWorktreePath( + command.worktreePath, + project.workspaceRoot, + ); return checkoutSelectedEvents({ thread, branch: command.branch, - worktreePath: normalizeWorktreePath(command.worktreePath, project.workspaceRoot), + worktreePath: selectedWorktreePath, + ...(command.existingWorktree === true && selectedWorktreePath !== null + ? { worktreeOrigin: { kind: "existing" as const } } + : {}), occurredAt, base: () => withEventBase({ diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 741adf1c4..39d1bdce5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -27,7 +27,8 @@ const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", }); const TestLayer = GitVcsDriver.layer.pipe( - Layer.provide(ServerConfigLayer), + // Merged, not just provided: a test reads where Threadlines keeps its own worktrees. + Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); @@ -376,6 +377,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + // Picking up worktrees a repository already had: only the ones made + // outside Threadlines are offered, each with what its row shows. + it.effect("lists the checkouts made elsewhere, apart from the ones Threadlines made", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfig; + + const elsewherePath = path.join(yield* makeTmpDir(), "retry"); + const managedPath = path.join(worktreesDir, "repo", "made-for-a-thread"); + yield* git(cwd, ["worktree", "add", "-b", "fix/retry", elsewherePath]); + yield* git(cwd, ["worktree", "add", "-b", "threadlines/abc", managedPath]); + yield* writeTextFile(elsewherePath, "one.ts", "export const one = 1;\n"); + yield* writeTextFile(elsewherePath, "two.ts", "export const two = 2;\n"); + + const all = (yield* driver.listWorktreeStatuses({ cwd })).worktrees; + assert.deepStrictEqual( + all.map((worktree) => [worktree.refName, worktree.managed]).toSorted(), + [ + [all.find((worktree) => worktree.isRoot)?.refName ?? null, false], + ["fix/retry", false], + ["threadlines/abc", true], + ].toSorted(), + ); + + const { worktrees } = yield* driver.listWorktreeStatuses({ cwd, scope: "elsewhere" }); + assert.deepStrictEqual( + worktrees.map((worktree) => worktree.refName), + ["fix/retry"], + ); + assert.strictEqual(worktrees[0]?.changedFileCount, 2); + // The commit it is on is the repository's first one, made just now. + assert.isBelow(Math.abs(Date.parse(worktrees[0]?.lastCommitAt ?? "") - Date.now()), 60_000); + + // Named without being read: what is in it stays unknown, never "clean". + const named = (yield* driver.listWorktreeStatuses({ + cwd, + scope: "elsewhere", + statuses: false, + })).worktrees; + assert.deepStrictEqual( + named.map((worktree) => ({ + refName: worktree.refName, + dirtyUnknown: worktree.dirtyUnknown, + changedFileCount: worktree.changedFileCount, + unmergedCommitCount: worktree.unmergedCommitCount, + })), + [ + { + refName: "fix/retry", + dirtyUnknown: true, + changedFileCount: null, + unmergedCommitCount: null, + }, + ], + ); + }), + ); + // Checkouts left over from before a history rewrite share no commit with // the default branch. Counting there reports the branch's whole history as // unshipped work, so the listing says "unrelated" and skips the number. diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d5a43a3ee..f109a9a0c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4271,16 +4271,35 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .pipe(Effect.catch(() => Effect.succeed(path.resolve(candidate)))); /** - * Uncommitted changes in one checkout, or null when git could not read it (a - * timeout, a broken checkout). Unknown stays unknown: the cleanup dialog - * force-deletes whatever this reports as clean. + * How many paths have uncommitted changes in one checkout, or null when git + * could not read it (a timeout, a broken checkout). Unknown stays unknown: + * the cleanup dialog force-deletes whatever this reports as clean. */ - const readWorktreeDirty = (cwd: string): Effect.Effect => + const readWorktreeChangedFileCount = (cwd: string): Effect.Effect => executeGit("GitVcsDriver.listWorktreeStatuses.status", cwd, ["status", "--porcelain"], { timeoutMs: 10_000, allowNonZeroExit: true, }).pipe( - Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim().length > 0 : null)), + Effect.map((result) => + result.exitCode === 0 + ? result.stdout.split("\n").filter((line) => line.trim().length > 0).length + : null, + ), + Effect.catch(() => Effect.succeed(null)), + ); + + /** When the commit a checkout is on was made, or null (no commit yet, unreadable). */ + const readWorktreeLastCommitAt = (cwd: string): Effect.Effect => + executeGit( + "GitVcsDriver.listWorktreeStatuses.lastCommit", + cwd, + ["log", "-1", "--format=%cI", "HEAD"], + { timeoutMs: 5_000, allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + const at = result.exitCode === 0 ? result.stdout.trim() : ""; + return at.length > 0 ? at : null; + }), Effect.catch(() => Effect.succeed(null)), ); @@ -4631,14 +4650,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const mainWorktreePath = yield* resolveMainWorktreePath(input.cwd); const mainRealPath = mainWorktreePath === null ? null : yield* realPathOrSelf(mainWorktreePath); + const managedRoot = yield* realPathOrSelf(worktreesDir); const freshenedBaseRefs = new Map(); const worktrees = yield* Effect.forEach( registrations, (registration) => Effect.gen(function* () { - const isRoot = - mainRealPath !== null && (yield* realPathOrSelf(registration.path)) === mainRealPath; + const realPath = yield* realPathOrSelf(registration.path); + const isRoot = mainRealPath !== null && realPath === mainRealPath; + // One Threadlines made for a thread: it lives in its own folder. + const relativeToManaged = path.relative(managedRoot, realPath); + const managed = + relativeToManaged.length > 0 && + !relativeToManaged.startsWith("..") && + !path.isAbsolute(relativeToManaged); + // Only what a user could pick up: its statuses are the costly part, + // and a repository can hold hundreds Threadlines made itself. + if (input.scope === "elsewhere" && (isRoot || managed)) { + return null; + } const exists = yield* fileSystem .exists(registration.path) .pipe(Effect.catch(() => Effect.succeed(true))); @@ -4654,11 +4685,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* mergedByContent: false, missing: true, lockReason: registration.lockReason, + managed, + changedFileCount: null, + lastCommitAt: null, + } satisfies VcsWorktreeStatus; + } + if (input.statuses === false) { + // Named, not read: what is in it is for whoever asks next. + return { + path: registration.path, + refName: registration.branch, + isRoot, + dirty: true, + dirtyUnknown: true, + unmergedCommitCount: null, + unrelatedHistory: false, + mergedByContent: false, + missing: false, + lockReason: registration.lockReason, + managed, + changedFileCount: null, + lastCommitAt: null, } satisfies VcsWorktreeStatus; } // A registration git would prune has lost its `.git` link, so // `git status` there would read whatever repository encloses it. - const dirty = registration.prunable ? null : yield* readWorktreeDirty(registration.path); + const changedFileCount = registration.prunable + ? null + : yield* readWorktreeChangedFileCount(registration.path); + const lastCommitAt = registration.prunable + ? null + : yield* readWorktreeLastCommitAt(registration.path); // Branches are shared by every checkout, so the repository answers // for all of them, including a half-deleted one. const shipping = yield* readBranchShipping( @@ -4670,18 +4727,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* path: registration.path, refName: registration.branch, isRoot, - dirty: dirty !== false, - dirtyUnknown: dirty === null, + dirty: changedFileCount !== 0, + dirtyUnknown: changedFileCount === null, unmergedCommitCount: shipping.count, unrelatedHistory: shipping.unrelatedHistory, mergedByContent: shipping.mergedByContent, missing: false, lockReason: registration.lockReason, + managed, + changedFileCount, + lastCommitAt, } satisfies VcsWorktreeStatus; }), { concurrency: WORKTREE_STATUS_CONCURRENCY }, ); - return { worktrees }; + return { worktrees: worktrees.filter((worktree) => worktree !== null) }; }); const readListRefsRepositoryContext = Effect.fn("readListRefsRepositoryContext")(function* ( diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 8b7b6b60a..923411674 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,5 +1,6 @@ import { scopeProjectRef, scopeThreadRef } from "@threadlines/client-runtime"; -import type { EnvironmentId, ThreadId } from "@threadlines/contracts"; +import type { EnvironmentId, ThreadId, VcsWorktreeStatus } from "@threadlines/contracts"; +import { areFilesystemPathsEqual } from "@threadlines/shared/path"; import { ChevronDownIcon, CloudIcon, @@ -9,13 +10,17 @@ import { FolderSyncIcon, MonitorIcon, } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useCallback, useMemo } from "react"; +import { useThreadCheckoutSelection } from "../hooks/useThreadCheckoutSelection"; import { useThreadTitle } from "../hooks/useThreadTitle"; +import { useWorktreePickup } from "../hooks/useWorktreePickup"; +import { lendingThreadId } from "../worktreeOrigin"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useIsMobile } from "../hooks/useMediaQuery"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; +import { worktreePathLabel } from "../worktreePickup"; import { type EnvMode, type EnvironmentOption, @@ -89,6 +94,10 @@ interface MobileRunContextSelectorProps { /** See BranchToolbarEnvModeSelector's `sharedWorktreeOwner`. */ sharedWorktreeOwner: string | null | undefined; onEnvModeChange: (mode: EnvMode) => void; + /** See BranchToolbarEnvModeSelector's `existingWorktrees`. */ + existingWorktrees: ReadonlyArray; + projectCwd: string; + onExistingWorktreeSelect: (worktree: VcsWorktreeStatus) => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -103,6 +112,9 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ activeWorktreePath, sharedWorktreeOwner, onEnvModeChange, + existingWorktrees, + projectCwd, + onExistingWorktreeSelect, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, @@ -220,6 +232,27 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ + {!envModeLocked && existingWorktrees.length > 0 ? ( + <> + + + Existing worktree + {existingWorktrees.map((worktree) => ( + onExistingWorktreeSelect(worktree)}> + + + + {worktree.refName ?? "Detached"} + + + {worktreePathLabel(worktree.path, projectCwd)} + + + + ))} + + + ) : null} ); @@ -276,7 +309,7 @@ export const BranchToolbar = memo(function BranchToolbar({ // session works there: the label says it is shared, and whose it is. const worktreeLenderId = serverThread?.effectiveCwd == null && activeWorktreePath !== null - ? (serverThread?.worktreeOrigin?.threadId ?? null) + ? lendingThreadId(serverThread?.worktreeOrigin) : null; const worktreeLenderTitle = useThreadTitle(environmentId, worktreeLenderId); const sharedWorktreeOwner = worktreeLenderId === null ? undefined : worktreeLenderTitle; @@ -298,6 +331,53 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); + // The repository's worktrees made outside Threadlines, while the workspace + // can still be chosen (docs/design/worktree-pickup.md). A draft is pointed + // at the one picked; a saved thread that has not started moves into it. + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const { candidates: pickupCandidates } = useWorktreePickup({ + projectRef: activeProject?.kind === "workspace" ? activeProjectRef : null, + projectCwd: activeProject?.cwd ?? null, + enabled: hasActiveThread && !envModeLocked, + }); + // The one the thread is pointed at already is "Current worktree" above them. + const existingWorktrees = useMemo( + () => + activeWorktreePath === null + ? pickupCandidates + : pickupCandidates.filter( + (worktree) => !areFilesystemPathsEqual(worktree.path, activeWorktreePath), + ), + [activeWorktreePath, pickupCandidates], + ); + const selectServerThreadCheckout = useThreadCheckoutSelection({ + threadRef: serverThread ? threadRef : null, + thread: serverThread ?? null, + }); + const onExistingWorktreeSelect = useCallback( + (worktree: VcsWorktreeStatus) => { + if (serverThread !== undefined) { + selectServerThreadCheckout(worktree.refName, worktree.path, { existingWorktree: true }); + } else { + setDraftThreadContext(draftId ?? threadRef, { + branch: worktree.refName, + worktreePath: worktree.path, + existingWorktree: true, + envMode: "worktree", + }); + } + onComposerFocusRequest?.(); + }, + [ + draftId, + onComposerFocusRequest, + selectServerThreadCheckout, + serverThread, + setDraftThreadContext, + threadRef, + ], + ); + // Two computers to pick from, or one and a way to add another. const showEnvironmentPicker = Boolean( availableEnvironments && @@ -357,6 +437,9 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={displayWorktreePath} onEnvModeChange={onEnvModeChange} + existingWorktrees={existingWorktrees} + projectCwd={activeProject.cwd} + onExistingWorktreeSelect={onExistingWorktreeSelect} />{" "} {sentence.branchPreposition}{" "} ) : (
@@ -415,6 +501,9 @@ export const BranchToolbar = memo(function BranchToolbar({ activeWorktreePath={displayWorktreePath} sharedWorktreeOwner={sharedWorktreeOwner} onEnvModeChange={onEnvModeChange} + existingWorktrees={existingWorktrees} + projectCwd={activeProject.cwd} + onExistingWorktreeSelect={onExistingWorktreeSelect} />
)} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 3c5449f13..d492052b3 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -32,6 +32,7 @@ import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useNewWorktreeBaseBranch } from "../hooks/useNewWorktreeBaseBranch"; import { useThreadCheckoutSelection } from "../hooks/useThreadCheckoutSelection"; +import { useWorktreesMadeElsewhere } from "../hooks/useWorktreePickup"; import { annotateMissingCheckoutLabel, deriveLocalBranchNameFromRemoteRef, @@ -170,6 +171,13 @@ export function BranchToolbarBranchSelector({ ? activeThreadBranchOverrideFromSetting === true : !serverThread && draftThread?.branchFromSetting === true; const activeProjectCwd = activeProject?.cwd ?? null; + // A branch can live in a worktree made outside Threadlines: a thread sent + // there by this picker is a guest in it, as one picked up from the + // new-thread screen is (docs/design/worktree-pickup.md). + const { isMadeElsewhere } = useWorktreesMadeElsewhere({ + environmentId, + cwd: activeProject?.kind === "workspace" ? activeProjectCwd : null, + }); const storedWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const activeWorktreePath = resolveActiveWorktreePath(activeProjectCwd, storedWorktreePath); const branchCwd = activeProjectCwd @@ -204,8 +212,14 @@ export function BranchToolbarBranchSelector({ options?: { readonly fromSetting?: boolean }, ) => { if (!activeThreadId || !activeProject) return; + // Left unsaid when it is not one: a thread already a guest where it + // stays keeps that. + const guest = + worktreePath !== null && isMadeElsewhere(worktreePath) + ? ({ existingWorktree: true } as const) + : undefined; if (hasServerThread) { - selectServerThreadCheckout(branch, worktreePath); + selectServerThreadCheckout(branch, worktreePath, guest); return; } const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ @@ -217,6 +231,7 @@ export function BranchToolbarBranchSelector({ branch, branchFromSetting: options?.fromSetting === true, worktreePath, + ...guest, envMode: nextDraftEnvMode, projectRef: scopeProjectRef(environmentId, activeProject.id), }); @@ -226,6 +241,7 @@ export function BranchToolbarBranchSelector({ activeProject, activeWorktreePath, hasServerThread, + isMadeElsewhere, selectServerThreadCheckout, setDraftThreadContext, draftId, diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index f148628e5..782a02e5b 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,3 +1,4 @@ +import type { VcsWorktreeStatus } from "@threadlines/contracts"; import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; import { memo, type ReactNode, useMemo } from "react"; @@ -20,6 +21,7 @@ import { SelectTrigger, SelectValue, } from "./ui/select"; +import { worktreePathLabel } from "../worktreePickup"; interface BranchToolbarEnvModeSelectorProps { /** `sentence` sets the picker in running words: plain text, dotted underline. */ @@ -33,8 +35,19 @@ interface BranchToolbarEnvModeSelectorProps { */ sharedWorktreeOwner?: string | null | undefined; onEnvModeChange: (mode: EnvMode) => void; + /** + * The repository's worktrees made outside Threadlines that no thread works + * in: the third choice, listed under its own heading. + */ + existingWorktrees?: ReadonlyArray | undefined; + /** The project's folder, which their paths are written from. */ + projectCwd?: string | undefined; + onExistingWorktreeSelect?: ((worktree: VcsWorktreeStatus) => void) | undefined; } +/** A menu value for an existing worktree, beside the two modes. */ +const EXISTING_WORKTREE_VALUE_PREFIX = "existing:"; + /** A workspace choice in the menu: its name, and one line on what picking it does. */ function WorkspaceOption(props: { icon: ReactNode; label: string; description: string }) { return ( @@ -55,6 +68,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe activeWorktreePath, sharedWorktreeOwner, onEnvModeChange, + existingWorktrees, + projectCwd, + onExistingWorktreeSelect, }: BranchToolbarEnvModeSelectorProps) { // The trigger shows the chosen item's label, so the sentence gets the // phrasing that reads mid-sentence and the chip keeps its title. @@ -107,7 +123,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ); diff --git a/apps/web/src/components/ChatView.drafts.browser.tsx b/apps/web/src/components/ChatView.drafts.browser.tsx index eac7bf8b7..e31b38790 100644 --- a/apps/web/src/components/ChatView.drafts.browser.tsx +++ b/apps/web/src/components/ChatView.drafts.browser.tsx @@ -1,4 +1,10 @@ -import { MessageId, ProviderInstanceId, WS_METHODS, type ThreadId } from "@threadlines/contracts"; +import { + MessageId, + ORCHESTRATION_WS_METHODS, + ProviderInstanceId, + WS_METHODS, + type ThreadId, +} from "@threadlines/contracts"; import { createModelSelection } from "@threadlines/shared/model"; import { page, userEvent } from "vite-plus/test/browser"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -339,6 +345,103 @@ describe("ChatView (full app): new threads and drafts", () => { } }); + it("offers the project's worktrees made elsewhere, for the draft to work in or to add as threads", async () => { + const worktree = (name: string, refName: string, facts: Record) => ({ + path: `/repo/code/${name}`, + refName, + isRoot: false, + dirty: false, + dirtyUnknown: false, + unmergedCommitCount: 0, + unrelatedHistory: false, + mergedByContent: false, + missing: false, + lockReason: null, + managed: false, + changedFileCount: 0, + lastCommitAt: null, + ...facts, + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-worktree-pickup-test" as MessageId, + targetText: "worktree pickup test", + }), + resolveRpc: (body) => + body._tag === WS_METHODS.vcsListWorktrees && body.scope === "elsewhere" + ? { + worktrees: [ + worktree("shop-checkout-retry", "fix/checkout-retry", { + dirty: true, + changedFileCount: 3, + unmergedCommitCount: 2, + }), + // Nothing left to ship in it: the list starts it unticked. + worktree("shop-old-banner", "chore/old-banner", {}), + ], + } + : undefined, + }); + + try { + await page.getByTestId("new-thread-button").click(); + const draftPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should change to a new draft thread.", + ); + const draftId = draftIdFromPath(draftPath); + // A server from before worktree pickup is never asked. + expect(document.querySelector('[data-testid="worktree-pickup-fold"]')).toBeNull(); + const descriptor = readPrimaryEnvironmentDescriptor(); + updatePrimaryEnvironmentDescriptor({ + ...descriptor!, + capabilities: { ...descriptor!.capabilities, worktreePickup: true }, + }); + + // A project added earlier only gets the count, folded beside "recent". + const fold = page.getByTestId("worktree-pickup-fold"); + await expect.element(fold).toHaveTextContent("2 worktrees"); + await fold.click(); + await expect.element(page.getByText("3 changed · 2 ahead")).toBeVisible(); + + await page.getByRole("button", { name: "Work in fix/checkout-retry" }).click(); + await vi.waitFor(() => { + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toMatchObject({ + branch: "fix/checkout-retry", + worktreePath: "/repo/code/shop-checkout-retry", + existingWorktree: true, + envMode: "worktree", + }); + }); + await expect.element(page.getByText("This thread")).toBeVisible(); + + // Laid out for picking, the one with work in it is the one ticked. + await page.getByRole("button", { name: "Add as threads…" }).click(); + await page.getByRole("button", { name: "Add 1 thread", exact: true }).click(); + await vi.waitFor(() => { + const created = wsRequests.filter( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.create", + ); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ + title: "Checkout retry", + branch: "fix/checkout-retry", + worktreePath: "/repo/code/shop-checkout-retry", + worktreeOrigin: { kind: "existing" }, + }); + }); + // Answered: the list folds back to its count. + await expect.element(page.getByTestId("worktree-pickup-fold")).toBeInTheDocument(); + expect(document.querySelector('[data-testid="worktree-pickup-offer"]')).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + it("creates a new draft instead of reusing a promoting draft thread", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 35621818f..08faaf0e3 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -225,6 +225,9 @@ export function buildLocalDraftThread( latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, + ...(draftThread.existingWorktree === true && draftThread.worktreePath !== null + ? { worktreeOrigin: { kind: "existing" as const } } + : {}), effectiveCwd: null, goal: null, turnDiffSummaries: [], @@ -1760,6 +1763,23 @@ export function firstWorkNeedsWorktree(input: { * them before the work starts, so an agent never starts in the project folder * on a thread meant for a worktree. Undefined when there is nothing to make. */ +/** + * The guest marker for a thread made in `thread`'s worktree + * (ThreadWorktreeOrigin `existing`): a draft pointed at a worktree made + * outside Threadlines, or a thread that continues where a guest works. Empty + * for a computer that predates worktree pickup, which would refuse it. + */ +export function existingWorktreeCreateFields(input: { + readonly thread: Pick; + readonly supported: boolean; +}): { readonly worktreeOrigin?: { readonly kind: "existing" } } { + return input.supported && + input.thread.worktreePath !== null && + (input.thread.worktreeOrigin ?? null) !== null + ? { worktreeOrigin: { kind: "existing" } } + : {}; +} + export function buildThreadStartBootstrap(input: { readonly createThread: ThreadBootstrapCreateThread | null; readonly worktree: { readonly projectCwd: string; readonly baseBranch: string } | null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3f3647cfa..eac42c480 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,7 @@ -import { useConnectedComputerCapability } from "../environments/computerCapabilities"; +import { + readConnectedComputerCapability, + useConnectedComputerCapability, +} from "../environments/computerCapabilities"; import { type ClientOrchestrationCommand, type OrchestrationAgentPage, @@ -301,7 +304,13 @@ import { type TimelineProposedPlanState, type TimelineTurnAgentsState, } from "./chat/MessagesTimeline"; -import { DraftEmptyState, DraftRecentThreads } from "./chat/DraftEmptyState"; +import { + DraftEmptyState, + DraftRecentThreads, + type DraftWorktreePickup, +} from "./chat/DraftEmptyState"; +import { addWorktreeThreads } from "../worktreePickup"; +import { ExistingWorktreeThreadEmptyState } from "./chat/WorktreePickup"; import { useDraftHeroCapable, useDraftHeroComposerSlide } from "./chat/useDraftHero"; import { ProviderModelPicker } from "./chat/ProviderModelPicker"; import { @@ -408,6 +417,7 @@ import { requestSetupTerminalReveal, settleSetupTerminalReveal, buildThreadStartBootstrap, + existingWorktreeCreateFields, firstWorkNeedsWorktree, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -1599,6 +1609,71 @@ export default function ChatView(props: ChatViewProps) { heroCapable: draftHeroCapable, }); + // The project's worktrees made outside Threadlines, offered under the + // composer (docs/design/worktree-pickup.md): a row points this draft at + // one, and the list makes threads with the composer's model and access. + const draftProjectId = draftThread?.projectId ?? null; + const draftWorktreePath = draftThread?.worktreePath ?? null; + const pickupProjectCwd = activeProject?.cwd ?? null; + const worktreePickup = useMemo(() => { + if ( + !isLocalDraftThread || + draftProjectId === null || + pickupProjectCwd === null || + isGeneralChatThread + ) { + return null; + } + return { + projectRef: scopeProjectRef(environmentId, draftProjectId), + projectCwd: pickupProjectCwd, + draftWorktreePath, + onWorkHere: (worktree) => { + setDraftThreadContext(composerDraftTarget, { + branch: worktree.refName, + worktreePath: worktree.path, + existingWorktree: true, + envMode: "worktree", + }); + // The next thing is the message: whoever picked the folder types it. + window.requestAnimationFrame(() => composerRef.current?.focusAtEnd()); + }, + onAddThreads: async (worktrees) => { + try { + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx) { + throw new Error("The message box isn't ready yet. Try again."); + } + await addWorktreeThreads({ + environmentId, + projectId: draftProjectId, + worktrees, + modelSelection: createModelSelection( + sendCtx.selectedModelSelection.instanceId, + sendCtx.selectedModel || DEFAULT_MODEL, + sendCtx.selectedModelSelection.options, + ), + runtimeMode, + }); + } catch (error) { + toastManager.add(failureNotice({ title: "Couldn't add the threads", error })); + throw error; + } + }, + }; + }, [ + composerDraftTarget, + composerRef, + draftProjectId, + draftWorktreePath, + environmentId, + isGeneralChatThread, + isLocalDraftThread, + pickupProjectCwd, + runtimeMode, + setDraftThreadContext, + ]); + const draftTimelineEmptyState = useMemo( () => isLocalDraftThread && draftThread ? ( @@ -1607,6 +1682,7 @@ export default function ChatView(props: ChatViewProps) { currentProjectName={activeProject?.name ?? null} isGeneralChat={isGeneralChatThread} layout={draftHeroCapable ? "hero" : "stacked"} + worktreePickup={worktreePickup} /> ) : undefined, [ @@ -1616,9 +1692,29 @@ export default function ChatView(props: ChatViewProps) { draftThread?.projectId, isGeneralChatThread, isLocalDraftThread, + worktreePickup, ], ); + // A thread added for a worktree made outside Threadlines has no draft + // screen to open on: before its first message it says where it works. + const pickedUpWorktreePath = + isServerThread && activeThread?.worktreeOrigin?.kind === "existing" + ? activeThread.worktreePath + : null; + const pickedUpBranch = activeThread?.branch ?? null; + const existingWorktreeEmptyState = useMemo( + () => + pickedUpWorktreePath !== null ? ( + + ) : undefined, + [pickedUpBranch, pickedUpWorktreePath, pickupProjectCwd], + ); + const shouldRetainThreadDetailSubscription = routeKind === "server" || serverThread !== undefined; useEffect(() => { if (!shouldRetainThreadDetailSubscription) { @@ -6048,6 +6144,10 @@ export default function ChatView(props: ChatViewProps) { interactionMode, branch: activeThreadBranch, worktreePath: activeThread.worktreePath, + ...existingWorktreeCreateFields({ + thread: activeThread, + supported: readConnectedComputerCapability(environmentId, "worktreePickup"), + }), ...draftRoomCreateFields, createdAt: activeThread.createdAt, } @@ -6631,6 +6731,10 @@ export default function ChatView(props: ChatViewProps) { interactionMode: "default" as const, branch: activeThreadBranch, worktreePath: activeThread.worktreePath, + ...existingWorktreeCreateFields({ + thread: activeThread, + supported: readConnectedComputerCapability(environmentId, "worktreePickup"), + }), ...draftRoomCreateFields, createdAt: new Date().toISOString(), } @@ -8104,6 +8208,10 @@ export default function ChatView(props: ChatViewProps) { interactionMode: "default", branch: activeThreadBranch, worktreePath: activeThread.worktreePath, + ...existingWorktreeCreateFields({ + thread: activeThread, + supported: readConnectedComputerCapability(environmentId, "worktreePickup"), + }), createdAt, }, }, @@ -8680,7 +8788,7 @@ export default function ChatView(props: ChatViewProps) {
- +
) : null} diff --git a/apps/web/src/components/CommandPaletteDialog.tsx b/apps/web/src/components/CommandPaletteDialog.tsx index bee56f131..c1e9f92a6 100644 --- a/apps/web/src/components/CommandPaletteDialog.tsx +++ b/apps/web/src/components/CommandPaletteDialog.tsx @@ -39,6 +39,7 @@ import { EraserIcon, FileTextIcon, FolderIcon, + FolderGitIcon, FolderPlusIcon, GitPullRequestIcon, HomeIcon, @@ -100,10 +101,12 @@ import { holdThreadDetailForOpen, prewarmHighlightedThread } from "../environmen import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useInboxThreads } from "../hooks/useInboxThreads"; import { useThreadMenu } from "../hooks/useThreadMenu"; +import { useWorktreePickup } from "../hooks/useWorktreePickup"; import type { ThreadMenuAction } from "./threadMenu.logic"; import { useIsMobile } from "../hooks/useMediaQuery"; import { useSettings } from "../hooks/useSettings"; import { selectSidebarListShown, useUiStateStore } from "../uiStateStore"; +import { useWorktreePickupStore } from "../worktreePickup"; import { useRoomsEnabled } from "../hooks/useRoomsEnabled"; import { readLocalApi } from "../localApi"; import { closeRightPanelSearchParams } from "../diffRouteSearch"; @@ -756,6 +759,19 @@ export function OpenCommandPaletteDialog() { ) ?? null) : null; const currentProjectCwd = currentProject?.cwd ?? null; + // The project's worktrees made outside Threadlines, asked for while the + // palette is open: the action below shows only when there is one to add. + const currentProjectRef = useMemo( + () => + currentProject?.kind === "workspace" + ? scopeProjectRef(currentProject.environmentId, currentProject.id) + : null, + [currentProject?.environmentId, currentProject?.id, currentProject?.kind], + ); + const worktreePickup = useWorktreePickup({ + projectRef: currentProjectRef, + projectCwd: currentProjectRef === null ? null : currentProjectCwd, + }); const currentEnvironmentProviders = useMemo( () => currentProjectEnvironmentId && currentProjectEnvironmentId === primaryEnvironmentId @@ -1660,6 +1676,24 @@ export function OpenCommandPaletteDialog() { }, }); } + + if (currentProjectRef !== null && worktreePickup.candidates.length > 0) { + const projectRef = currentProjectRef; + actionItems.push({ + kind: "action", + value: "action:pick-up-worktrees", + searchTerms: ["worktree", "existing", "pick up", "import", "branch", "add threads"], + title: "Add existing worktrees as threads…", + description: "Worktrees this repository already has, made outside Threadlines", + icon: , + // Lays the list out on the new-thread screen, where each worktree + // can be ticked; it also brings a hidden count back. + run: async () => { + useWorktreePickupStore.getState().offer(scopedProjectKey(projectRef)); + await handleNewThread(projectRef, { continueActiveCheckout: false }); + }, + }); + } } actionItems.push({ @@ -2292,6 +2326,9 @@ export function OpenCommandPaletteDialog() { createdAt: new Date().toISOString(), }); const createdProjectRef = scopeProjectRef(browseEnvironmentId, projectId); + // A repository added with worktrees already in it lays them out for + // picking on the screen that opens next (worktree pickup). + useWorktreePickupStore.getState().offer(scopedProjectKey(createdProjectRef)); // Let the created project reach the store first so the draft is keyed // to the project's real identity. If it does not arrive in time the // draft is adopted (re-keyed) on the next new-thread call instead. diff --git a/apps/web/src/components/RecentThreadsList.tsx b/apps/web/src/components/RecentThreadsList.tsx index d96b3477e..57ca06106 100644 --- a/apps/web/src/components/RecentThreadsList.tsx +++ b/apps/web/src/components/RecentThreadsList.tsx @@ -1,7 +1,7 @@ import { scopedProjectKey, scopeProjectRef, scopeThreadRef } from "@threadlines/client-runtime"; import { useNavigate } from "@tanstack/react-router"; import { ChevronDownIcon, MessagesSquareIcon } from "lucide-react"; -import { useId, useMemo, useState } from "react"; +import { type ReactNode, useId, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -17,6 +17,17 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; import { RelativeTimeLabel } from "./RelativeTimeLabel"; +/** + * A second list this screen keeps beside the recent threads (a project's + * worktrees to pick up): its own count in the counts line, and a panel that + * takes the rows' place while it is open. One of the two is open at a time. + */ +export interface RecentThreadsSideFold { + readonly label: string; + readonly testId: string; + readonly panel: ReactNode; +} + interface RecentThreadsListProps { limit?: number; testId: string; @@ -33,6 +44,7 @@ interface RecentThreadsListProps { * A new key starts folded again. */ foldKey?: string; + sideFold?: RecentThreadsSideFold | undefined; } export function RecentThreadsList({ @@ -41,11 +53,22 @@ export function RecentThreadsList({ className, scope = "all", foldKey, + sideFold, }: RecentThreadsListProps) { const foldable = foldKey !== undefined; - const [unfoldedKey, setUnfoldedKey] = useState(null); - const unfolded = foldable && unfoldedKey === foldKey; + // Which list is open, and for which key: a new key starts folded again. + // Without a key the recent rows always show, and only the side list folds. + const [opened, setOpened] = useState<{ + readonly key: string | undefined; + readonly list: "recent" | "side"; + } | null>(null); + const openList = opened !== null && opened.key === foldKey ? opened.list : null; + const unfolded = foldable && openList === "recent"; + const sideOpen = sideFold !== undefined && openList === "side"; + const toggle = (list: "recent" | "side") => + setOpened(openList === list ? null : { key: foldKey, list }); const rowsId = useId(); + const sidePanelId = useId(); const navigate = useNavigate(); const { orderedProjects } = useHandleNewThread(); const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); @@ -79,7 +102,7 @@ export function RecentThreadsList({ [orderedProjects], ); - if (recentThreads.length === 0) { + if (recentThreads.length === 0 && sideFold === undefined) { return null; } @@ -88,35 +111,71 @@ export function RecentThreadsList({ ).length; const recentCount = recentThreads.length - workingCount; + const foldToggleClassName = + "inline-flex cursor-pointer items-center gap-1 rounded-xs text-xs font-medium text-muted-foreground transition-colors focus-ring hover:text-foreground"; + const sideToggle = + sideFold !== undefined ? ( + + ) : null; + const hasRecentThreads = recentThreads.length > 0; + return (
{foldable ? ( -
- +
+ {hasRecentThreads ? ( + + ) : null} + {sideToggle}
) : ( -
- {scope === "chats" ? "Active and recent chats" : "Active and recent"} -
+ <> + {sideToggle !== null ? ( +
{sideToggle}
+ ) : null} + {sideOpen ? ( +
+ {sideFold?.panel} +
+ ) : null} + {hasRecentThreads ? ( +
+ {scope === "chats" ? "Active and recent chats" : "Active and recent"} +
+ ) : null} + )} + {foldable && sideOpen ?
{sideFold?.panel}
: null}
diff --git a/apps/web/src/components/chat/DraftEmptyState.tsx b/apps/web/src/components/chat/DraftEmptyState.tsx index a3f82bdb7..d7b6f3ab4 100644 --- a/apps/web/src/components/chat/DraftEmptyState.tsx +++ b/apps/web/src/components/chat/DraftEmptyState.tsx @@ -1,12 +1,13 @@ import { scopedProjectKey, scopeProjectRef } from "@threadlines/client-runtime"; import type { ScopedProjectRef } from "@threadlines/contracts"; import { CloudIcon, MessagesSquareIcon, MonitorIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { usePrimaryEnvironmentId } from "../../environments/primary"; import { useSavedEnvironmentRegistryStore } from "../../environments/runtime"; import { useHandleNewThread } from "../../hooks/useHandleNewThread"; import { useSidebarProjectSnapshots } from "../../hooks/useSidebarProjectSnapshots"; +import { useWorktreePickup } from "../../hooks/useWorktreePickup"; import { startNewGeneralChatThread } from "../../lib/chatThreadActions"; import { resolveGeneralChatsProjectRef } from "../../lib/generalChats"; import { orderSnapshotsByProjectRefs } from "../../sidebarProjectGrouping"; @@ -14,6 +15,12 @@ import { selectGeneralChatsProjectAcrossEnvironments, useStore } from "../../sto import { cn } from "../../lib/utils"; import { ProjectFavicon } from "../ProjectFavicon"; import { RecentThreadsList } from "../RecentThreadsList"; +import { useWorktreePickupStore } from "../../worktreePickup"; +import { + type WorktreePickupActions, + WorktreePickupOffer, + WorktreePickupRows, +} from "./WorktreePickup"; import { OpenSourceSupportLinks } from "../OpenSourceSupportLinks"; import { riseDelay, ThreadlinesFigure } from "../ThreadlinesFigure"; import { ProjectThreadMap } from "./ProjectThreadMap"; @@ -38,31 +45,83 @@ import { */ export type DraftEmptyStateLayout = "hero" | "stacked"; +/** The worktree pickup a draft's screen offers; null for a general chat. */ +export type DraftWorktreePickup = WorktreePickupActions; + interface DraftEmptyStateProps { currentProjectRef: ScopedProjectRef | null; currentProjectName: string | null; isGeneralChat: boolean; layout: DraftEmptyStateLayout; + worktreePickup: DraftWorktreePickup | null; } /** - * What follows the question: where the user left off, and the repository links. - * `foldKey` is for the hero layout, whose popups open over this list. + * What follows the question: the project's worktrees to pick up, where the + * user left off, and the repository links. `foldKey` is for the hero layout, + * whose popups open over this list. */ export function DraftRecentThreads({ isGeneralChat, foldKey, + worktreePickup, }: { isGeneralChat: boolean; foldKey?: string; + worktreePickup: DraftWorktreePickup | null; }) { + const pickup = useWorktreePickup({ + projectRef: worktreePickup?.projectRef ?? null, + projectCwd: worktreePickup?.projectCwd ?? null, + }); + const { projectKey, candidates, loaded } = pickup; + const offer = useWorktreePickupStore((state) => state.offer); + const closeOffer = useWorktreePickupStore((state) => state.closeOffer); + const hide = useWorktreePickupStore((state) => state.hide); + // An offer with nothing in it is over: a repository added without worktrees + // must not lay the list out months later, when it gets its first. + const offerIsEmpty = pickup.offered && loaded && candidates.length === 0; + useEffect(() => { + if (offerIsEmpty && projectKey !== null) { + closeOffer(projectKey); + } + }, [closeOffer, offerIsEmpty, projectKey]); + + const hasWorktrees = worktreePickup !== null && projectKey !== null && candidates.length > 0; + const showsOffer = hasWorktrees && pickup.offered; return ( <> + {showsOffer ? ( + closeOffer(projectKey)} + /> + ) : null} hide(projectKey)} + onOffer={() => offer(projectKey)} + /> + ), + } + : undefined + } testId="draft-empty-recent-thread" /> @@ -75,6 +134,7 @@ export function DraftEmptyState({ currentProjectName, isGeneralChat, layout, + worktreePickup, }: DraftEmptyStateProps) { const { handleNewThread, orderedProjects } = useHandleNewThread(); const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); @@ -281,7 +341,9 @@ export function DraftEmptyState({ ? - {layout === "stacked" ? : null} + {layout === "stacked" ? ( + + ) : null}
); } diff --git a/apps/web/src/components/chat/WorktreePickup.tsx b/apps/web/src/components/chat/WorktreePickup.tsx new file mode 100644 index 000000000..0428328d0 --- /dev/null +++ b/apps/web/src/components/chat/WorktreePickup.tsx @@ -0,0 +1,262 @@ +/** + * The new-thread screen's two views of a project's worktrees made outside + * Threadlines (docs/design/worktree-pickup.md): the list laid out for picking + * right after the repository is added, and the rows behind the folded count + * every later visit. Both read what is in each worktree when they are shown; + * the count that leads to them only needed their names. + */ +import type { ScopedProjectRef, VcsWorktreeStatus } from "@threadlines/contracts"; +import { areFilesystemPathsEqual, shortenHomePath } from "@threadlines/shared/path"; +import { useState } from "react"; + +import { useWorktreePickup } from "../../hooks/useWorktreePickup"; +import { isSettledWorktree, worktreePathLabel } from "../../worktreePickup"; +import { RelativeTimeLabel } from "../RelativeTimeLabel"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; + +/** + * The project whose worktrees the screen offers, and what its draft does + * with one; the chat view supplies it. + */ +export interface WorktreePickupActions { + readonly projectRef: ScopedProjectRef; + readonly projectCwd: string; + /** Where the draft on screen points, to mark its row. */ + readonly draftWorktreePath: string | null; + /** Points the draft on screen at the worktree. */ + readonly onWorkHere: (worktree: VcsWorktreeStatus) => void; + /** Makes a thread for each, with the message box's model and access. No turn starts. */ + readonly onAddThreads: (worktrees: ReadonlyArray) => Promise; +} + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +/** The branch, or what stands in for one on a detached worktree. */ +function worktreeName(worktree: VcsWorktreeStatus): string { + return worktree.refName ?? "Detached"; +} + +/** What is waiting in the worktree, in a few words; `known` false while it is being read. */ +function WorktreeFacts({ worktree, known }: { worktree: VcsWorktreeStatus; known: boolean }) { + if (!known) { + return Checking; + } + const facts: string[] = []; + if (worktree.changedFileCount !== null && worktree.changedFileCount > 0) { + facts.push(`${worktree.changedFileCount} changed`); + } + if (worktree.mergedByContent) { + facts.push("merged"); + } else if (worktree.unmergedCommitCount !== null && worktree.unmergedCommitCount > 0) { + facts.push(`${worktree.unmergedCommitCount} ahead`); + } + if (facts.length === 0 && !worktree.dirtyUnknown) { + facts.push("no changes"); + } + return ( + + {facts.length > 0 ? {facts.join(" · ")} : null} + {facts.length > 0 && worktree.lastCommitAt !== null ? ( + + ) : null} + {worktree.lastCommitAt !== null ? ( + + ) : null} + + ); +} + +function WorktreeLabel({ + worktree, + projectCwd, +}: { + worktree: VcsWorktreeStatus; + projectCwd: string; +}) { + return ( + + {worktreeName(worktree)} + + {worktreePathLabel(worktree.path, projectCwd)} + + + ); +} + +/** + * The list laid out for picking: a checkbox a worktree, the ones with nothing + * left to ship unticked, and one button that makes the threads. + */ +export function WorktreePickupOffer(props: { + readonly actions: WorktreePickupActions; + readonly onClose: () => void; + readonly className?: string; +}) { + const { actions } = props; + const { candidates: worktrees, statusesLoaded } = useWorktreePickup({ + projectRef: actions.projectRef, + projectCwd: actions.projectCwd, + statuses: true, + }); + // Only what the user changed is kept, so a worktree starts as its state + // says once that is read, and one that appears meanwhile does too. + const [choices, setChoices] = useState>>({}); + const [adding, setAdding] = useState(false); + const isPicked = (worktree: VcsWorktreeStatus) => + choices[worktree.path] ?? !(statusesLoaded && isSettledWorktree(worktree)); + const picked = worktrees.filter(isPicked); + + return ( +
+
+ + {plural(worktrees.length, "worktree", "worktrees")} already in this repository. + {" "} + Add the ones you want as threads. Nothing runs until you send a message. +
+
+ {worktrees.map((worktree) => ( + // The whole row is the checkbox's label. + + ))} +
+
+ + +
+
+ ); +} + +/** + * The rows behind the folded count: each worktree with the one thing to do + * there now, and the two ways out of the list under them. + */ +export function WorktreePickupRows(props: { + readonly actions: WorktreePickupActions; + readonly onOffer: () => void; + readonly onHide: () => void; +}) { + const { actions } = props; + const { candidates: worktrees, statusesLoaded } = useWorktreePickup({ + projectRef: actions.projectRef, + projectCwd: actions.projectCwd, + statuses: true, + }); + const { draftWorktreePath } = actions; + return ( +
+
+ {worktrees.map((worktree) => { + const isDraftHere = + draftWorktreePath !== null && areFilesystemPathsEqual(draftWorktreePath, worktree.path); + return ( +
+ + + {isDraftHere ? ( + + This thread + + ) : ( + + )} +
+ ); + })} +
+
+ + +
+
+ ); +} + +/** + * What a thread added for an existing worktree shows before its first + * message: where it works, and that nothing has run there yet. + */ +export function ExistingWorktreeThreadEmptyState(props: { + readonly worktreePath: string; + /** The project's folder, which the path is written from; null when unknown. */ + readonly projectCwd: string | null; + readonly branch: string | null; +}) { + return ( +
+

Nothing has run in this thread yet.

+

+ It works in the worktree at{" "} + + {props.projectCwd === null + ? shortenHomePath(props.worktreePath) + : worktreePathLabel(props.worktreePath, props.projectCwd)} + + {props.branch !== null ? ( + <> + {" "} + on {props.branch} + + ) : null} + . Send a message to start. Deleting the thread leaves the folder where it is. +

+
+ ); +} diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx index 43ffb626e..537ed59e1 100644 --- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx +++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx @@ -30,6 +30,7 @@ import { usePrimaryEnvironmentId } from "../../environments/primary"; import { useServerProviders } from "../../rpc/serverState"; import { selectProjectByRef, useStore } from "../../store"; import { useThreadTitle } from "../../hooks/useThreadTitle"; +import { lendingThreadId } from "../../worktreeOrigin"; import { useGitStatus } from "../../lib/gitStatusState"; import { resolveThreadWorkingCwd } from "@threadlines/shared/threadCwd"; import { createThreadSelectorByRef } from "../../storeSelectors"; @@ -244,7 +245,7 @@ function ThreadHoverCardContent({ thread, status, lineage }: ThreadHoverCardPayl // and tells you the checkout is isolated. const worktreeName = thread.worktreePath ? thread.worktreePath.split("/").pop() : null; // Another thread's worktree it was put in (ThreadWorktreeOrigin): say whose. - const worktreeLenderId = thread.worktreeOrigin?.threadId ?? null; + const worktreeLenderId = lendingThreadId(thread.worktreeOrigin); const worktreeLenderTitle = useThreadTitle(thread.environmentId, worktreeLenderId); // The row prints only a pinned branch; here there is room to answer the // question a branchless thread leaves open -- which ref its checkout is on. diff --git a/apps/web/src/components/source-control/SourceControlPanel.browser.tsx b/apps/web/src/components/source-control/SourceControlPanel.browser.tsx index 65b24da66..aaa66c326 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.browser.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.browser.tsx @@ -132,10 +132,13 @@ vi.mock("~/environments/runtime", () => { environmentRuntimeMock.connectionListeners.add(listener); return () => environmentRuntimeMock.connectionListeners.delete(listener); }), + // No saved computers: both stores are empty, in their real shape. useSavedEnvironmentRegistryStore: vi.fn((selector: (state: unknown) => unknown) => - selector({}), + selector({ byId: {} }), + ), + useSavedEnvironmentRuntimeStore: vi.fn((selector: (state: unknown) => unknown) => + selector({ byId: {} }), ), - useSavedEnvironmentRuntimeStore: vi.fn((selector: (state: unknown) => unknown) => selector({})), waitForSavedEnvironmentRegistryHydration: vi.fn(async () => undefined), }; }); @@ -357,6 +360,9 @@ function makeWorktreeStatus( mergedByContent: false, missing: false, lockReason: null, + managed: false, + changedFileCount: 0, + lastCommitAt: null, ...overrides, }; } diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx index 827edb5e3..206dc4ed8 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -106,6 +106,7 @@ import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useSettings } from "~/hooks/useSettings"; import { useCheckoutRecovery } from "~/hooks/useCheckoutRecovery"; import { useThreadCheckoutSelection } from "~/hooks/useThreadCheckoutSelection"; +import { useWorktreesMadeElsewhere } from "~/hooks/useWorktreePickup"; import { readLocalApi } from "~/localApi"; import { useComposerDraftStore } from "~/composerDraftStore"; import { @@ -235,7 +236,12 @@ interface SourceControlPanelProps { readonly target: SourceControlProjectTarget | null; readonly activeThreadRef: ScopedThreadRef | null; readonly onActiveBranchChange?: - | ((branch: string | null, worktreePath: string | null) => void) + | (( + branch: string | null, + worktreePath: string | null, + /** The worktree was made outside Threadlines (`DraftThreadState.existingWorktree`). */ + guest?: { readonly existingWorktree: true }, + ) => void) | undefined; readonly onOpenDiff?: (filePath?: string) => void; /** @@ -1413,7 +1419,12 @@ function SourceControlBranchMenu({ readonly target: SourceControlProjectTarget; readonly activeThreadRef: ScopedThreadRef | null; readonly onActiveBranchChange?: - | ((branch: string | null, worktreePath: string | null) => void) + | (( + branch: string | null, + worktreePath: string | null, + /** The worktree was made outside Threadlines (`DraftThreadState.existingWorktree`). */ + guest?: { readonly existingWorktree: true }, + ) => void) | undefined; readonly status: VcsStatusResult | null | undefined; readonly isBusy: boolean; @@ -1502,18 +1513,39 @@ function SourceControlBranchMenu({ repositorySafetyReason, }); + // A thread this panel sends into a worktree made outside Threadlines is a + // guest there (docs/design/worktree-pickup.md). + const { isMadeElsewhere } = useWorktreesMadeElsewhere({ + environmentId: target.environmentId, + cwd: target.projectCwd, + }); const syncActiveThreadBranch = useCallback( (branch: string | null, worktreePath: string | null = target.worktreePath) => { + // Left unsaid when it is not one: a guest that stays where it is keeps that. + const guest = + worktreePath !== null && isMadeElsewhere(worktreePath) + ? ({ existingWorktree: true } as const) + : undefined; if (onActiveBranchChange) { - onActiveBranchChange(branch, worktreePath); + if (guest) { + onActiveBranchChange(branch, worktreePath, guest); + } else { + onActiveBranchChange(branch, worktreePath); + } return; } if (!activeThreadRef) { return; } - selectServerThreadCheckout(branch, worktreePath); + selectServerThreadCheckout(branch, worktreePath, guest); }, - [activeThreadRef, onActiveBranchChange, selectServerThreadCheckout, target.worktreePath], + [ + activeThreadRef, + isMadeElsewhere, + onActiveBranchChange, + selectServerThreadCheckout, + target.worktreePath, + ], ); /** diff --git a/apps/web/src/components/source-control/WorktreeCleanupDialog.tsx b/apps/web/src/components/source-control/WorktreeCleanupDialog.tsx index d8499d01a..e756ba9c3 100644 --- a/apps/web/src/components/source-control/WorktreeCleanupDialog.tsx +++ b/apps/web/src/components/source-control/WorktreeCleanupDialog.tsx @@ -11,6 +11,7 @@ import { invalidateGitQueries, vcsListWorktreesQueryOptions, } from "~/lib/gitReactQuery"; +import { useConnectedComputerCapability } from "~/environments/computerCapabilities"; import { cn } from "~/lib/utils"; import { selectThreadsForEnvironment, useStore } from "~/store"; import { @@ -65,6 +66,8 @@ function useWorktreeCleanupRows( // Any failed read leaves the archived threads unknown, even with an older // list in hand: a thread archived since then would read as unused. const archivedThreadsUnknown = archived.error !== null; + // A server with worktree pickup says which worktrees Threadlines made. + const managedKnown = useConnectedComputerCapability(environmentId, "worktreePickup"); const rows = useMemo( () => classifyWorktreesForCleanup({ @@ -72,8 +75,9 @@ function useWorktreeCleanupRows( liveThreads, archivedThreads, archivedThreadsUnknown, + managedKnown, }), - [archivedThreads, archivedThreadsUnknown, liveThreads, worktrees], + [archivedThreads, archivedThreadsUnknown, liveThreads, managedKnown, worktrees], ); return { rows, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 292b4db48..e7b3976bf 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1562,6 +1562,37 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("remembers a worktree made outside Threadlines until the draft is pointed elsewhere", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setDraftThreadContext(draftId, { + branch: "fix/checkout-retry", + worktreePath: "/code/shop-checkout-retry", + existingWorktree: true, + }); + // Changes that leave the worktree alone keep it: another setting, and a + // branch made or switched inside it (the branch picker names the folder + // again). + store.setDraftThreadContext(draftId, { runtimeMode: "full-access" }); + store.setDraftThreadContext(draftId, { + branch: "fix/checkout-retry-2", + worktreePath: "/code/shop-checkout-retry", + envMode: "worktree", + }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + worktreePath: "/code/shop-checkout-retry", + existingWorktree: true, + }); + // Another worktree named without it is one of Threadlines' own. + store.setDraftThreadContext(draftId, { + branch: "feature/next", + worktreePath: "/tmp/feature-next", + }); + expect( + useComposerDraftStore.getState().getDraftThread(draftId)?.existingWorktree, + ).toBeUndefined(); + }); + it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9544b0398..5894006d9 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -302,6 +302,7 @@ const PersistedDraftThreadState = Schema.Struct({ branch: Schema.NullOr(Schema.String), branchFromSetting: Schema.optionalKey(Schema.Boolean), worktreePath: Schema.NullOr(Schema.String), + existingWorktree: Schema.optionalKey(Schema.Boolean), envMode: DraftThreadEnvModeSchema, room: Schema.optionalKey(DraftRoomSchema), promotedTo: Schema.optionalKey( @@ -417,6 +418,14 @@ export interface DraftSessionState { */ branchFromSetting?: true; worktreePath: string | null; + /** + * `worktreePath` is a worktree the thread did not get from Threadlines: one + * made outside it that the user picked up, or the one a guest thread it + * continues from works in. Its thread is made a guest there + * (ThreadWorktreeOrigin `existing`). Goes whenever the draft is pointed + * somewhere else. + */ + existingWorktree?: true; envMode: DraftThreadEnvMode; /** * Agents added before the first message; absent while it has none. Part of @@ -496,6 +505,8 @@ interface ComposerDraftStoreState { threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; + /** With `worktreePath`: see `DraftThreadState.existingWorktree`. */ + existingWorktree?: boolean; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; @@ -510,6 +521,8 @@ interface ComposerDraftStoreState { threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; + /** With `worktreePath`: see `DraftThreadState.existingWorktree`. */ + existingWorktree?: boolean; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; @@ -524,6 +537,8 @@ interface ComposerDraftStoreState { /** With `branch`: it came from the "New worktrees from" setting. */ branchFromSetting?: boolean; worktreePath?: string | null; + /** With `worktreePath`: see `DraftThreadState.existingWorktree`. */ + existingWorktree?: boolean; projectRef?: ScopedProjectRef; /** The sidebar project the draft now belongs to, when `projectRef` moves it to another. */ logicalProjectKey?: string; @@ -1628,6 +1643,7 @@ function createDraftThreadState( threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; + existingWorktree?: boolean; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; @@ -1678,6 +1694,13 @@ function createDraftThreadState( ? { branchFromSetting: true as const } : {}), worktreePath: nextWorktreePath, + ...(keepsExistingWorktree({ + nextWorktreePath, + requested: options, + previous: projectChanged ? undefined : existingThread, + }) + ? { existingWorktree: true as const } + : {}), envMode: options?.envMode ?? (nextWorktreePath @@ -1695,6 +1718,32 @@ function createDraftThreadState( }; } +/** + * Whether a draft's worktree is one it did not get from Threadlines + * (`DraftThreadState.existingWorktree`): what the caller says when it says, + * otherwise what the draft already held while it stays in that worktree. A + * caller that names the same worktree again (a branch made or switched + * inside it) does not take it away. + */ +function keepsExistingWorktree(input: { + readonly nextWorktreePath: string | null; + readonly requested: + | { readonly worktreePath?: string | null; readonly existingWorktree?: boolean } + | undefined; + readonly previous: Pick | undefined; +}): boolean { + if (input.nextWorktreePath === null) { + return false; + } + if (input.requested?.existingWorktree !== undefined) { + return input.requested.existingWorktree; + } + return ( + input.previous?.existingWorktree === true && + input.previous.worktreePath === input.nextWorktreePath + ); +} + function scopedThreadRefsEqual( left: ScopedThreadRef | null | undefined, right: ScopedThreadRef | null | undefined, @@ -1721,6 +1770,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.interactionMode === right.interactionMode && left.branch === right.branch && left.worktreePath === right.worktreePath && + left.existingWorktree === right.existingWorktree && left.envMode === right.envMode && left.room === right.room && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) @@ -1884,6 +1934,9 @@ function normalizePersistedDraftThreads( ? { branchFromSetting: true } : {}), worktreePath: normalizedWorktreePath, + ...(candidateDraftThread.existingWorktree === true && normalizedWorktreePath !== null + ? { existingWorktree: true } + : {}), envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), ...normalizeDraftRoom(candidateDraftThread.room), promotedTo, @@ -2562,6 +2615,9 @@ function toHydratedDraftThreadState( ? { branchFromSetting: true as const } : {}), worktreePath: persistedDraftThread.worktreePath, + ...(persistedDraftThread.existingWorktree === true && persistedDraftThread.worktreePath !== null + ? { existingWorktree: true as const } + : {}), envMode: persistedDraftThread.envMode, ...(persistedDraftThread.room !== undefined ? { room: persistedDraftThread.room } : {}), promotedTo: persistedDraftThread.promotedTo @@ -2880,6 +2936,13 @@ const composerDraftStore = create()( branch: nextBranch, ...(nextBranchFromSetting ? { branchFromSetting: true as const } : {}), worktreePath: nextWorktreePath, + ...(keepsExistingWorktree({ + nextWorktreePath, + requested: options, + previous: projectChanged ? undefined : existing, + }) + ? { existingWorktree: true as const } + : {}), envMode: options.envMode ?? (nextWorktreePath @@ -2900,6 +2963,7 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.branchFromSetting === existing.branchFromSetting && nextDraftThread.worktreePath === existing.worktreePath && + nextDraftThread.existingWorktree === existing.existingWorktree && nextDraftThread.envMode === existing.envMode && nextDraftThread.room === existing.room && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); diff --git a/apps/web/src/hooks/useThreadCheckoutSelection.ts b/apps/web/src/hooks/useThreadCheckoutSelection.ts index a322670be..355d4e2b7 100644 --- a/apps/web/src/hooks/useThreadCheckoutSelection.ts +++ b/apps/web/src/hooks/useThreadCheckoutSelection.ts @@ -37,7 +37,12 @@ export function useThreadCheckoutSelection(input: { const restoreThreadCheckout = useStore((store) => store.restoreThreadCheckout); return useCallback( - (branch: string | null, worktreePath: string | null): boolean => { + ( + branch: string | null, + worktreePath: string | null, + /** The worktree was made outside Threadlines: the thread is a guest there. */ + options?: { readonly existingWorktree?: boolean }, + ): boolean => { if (!threadRef || !thread) { return false; } @@ -67,6 +72,9 @@ export function useThreadCheckoutSelection(input: { threadId: threadRef.threadId, branch, worktreePath, + ...(options?.existingWorktree === true && worktreePath !== null + ? { existingWorktree: true } + : {}), }) .then( () => { diff --git a/apps/web/src/hooks/useWorktreePickup.ts b/apps/web/src/hooks/useWorktreePickup.ts new file mode 100644 index 000000000..8f082b3c5 --- /dev/null +++ b/apps/web/src/hooks/useWorktreePickup.ts @@ -0,0 +1,122 @@ +import { scopedProjectKey } from "@threadlines/client-runtime"; +import type { EnvironmentId, ScopedProjectRef, VcsWorktreeStatus } from "@threadlines/contracts"; +import { areFilesystemPathsEqual } from "@threadlines/shared/path"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { useConnectedComputerCapability } from "../environments/computerCapabilities"; +import { vcsWorktreesMadeElsewhereQueryOptions } from "../lib/gitReactQuery"; +import { selectSidebarThreadsAcrossEnvironments, useStore } from "../store"; +import { pickupCandidates, useWorktreePickupStore } from "../worktreePickup"; + +const NO_WORKTREES: ReadonlyArray = []; + +/** + * A repository's worktrees made outside Threadlines + * (docs/design/worktree-pickup.md). Empty for a computer whose server + * predates pickup. + * + * They come named only, which is cheap enough for a count, a menu, or asking + * whether a path is one of them. A list that shows what is in each asks with + * `statuses`: the names stand in until the computer has read them + * (`statusesLoaded`). + */ +export function useWorktreesMadeElsewhere(input: { + readonly environmentId: EnvironmentId | null; + /** Any checkout of the repository; every one lists the same worktrees. */ + readonly cwd: string | null; + /** False keeps from asking the computer, for a surface that is not offering any now. */ + readonly enabled?: boolean; + /** Also read what is in each worktree, for a list that shows it. */ + readonly statuses?: boolean; +}) { + const supported = useConnectedComputerCapability(input.environmentId, "worktreePickup"); + const enabled = supported && (input.enabled ?? true); + const namesQuery = useQuery( + vcsWorktreesMadeElsewhereQueryOptions({ + environmentId: input.environmentId, + cwd: input.cwd, + statuses: false, + enabled, + }), + ); + const statusesQuery = useQuery( + vcsWorktreesMadeElsewhereQueryOptions({ + environmentId: input.environmentId, + cwd: input.cwd, + statuses: true, + enabled: enabled && input.statuses === true, + }), + ); + // Only a list that asked for statuses reads them: they are fetched when it + // is shown, so anything kept from an earlier showing can be long out of + // date, and must not decide what a count or a menu lists. + const read = input.statuses === true ? statusesQuery.data?.worktrees : undefined; + // Nothing kept from before counts once the computer stops offering pickup + // (it went back a version) or the surface stops asking. + const worktrees = enabled ? (read ?? namesQuery.data?.worktrees ?? NO_WORKTREES) : NO_WORKTREES; + /** + * Whether a path is one of them, a thread's or not: a thread sent there is + * a guest in it (ThreadWorktreeOrigin `existing`). False until the computer + * has answered. + */ + const isMadeElsewhere = useCallback( + (path: string) => + worktrees.some( + (worktree) => !worktree.managed && areFilesystemPathsEqual(worktree.path, path), + ), + [worktrees], + ); + return { + worktrees, + isMadeElsewhere, + /** The computer has answered: an empty list means there are none. */ + loaded: enabled && (read !== undefined || namesQuery.isSuccess), + /** What is in each worktree is known, not just its name. */ + statusesLoaded: read !== undefined, + }; +} + +/** + * A project's worktrees made outside Threadlines that no thread works in yet, + * and what this device remembers about offering them. Empty for a general + * chat and a project not known yet. + */ +export function useWorktreePickup(input: { + readonly projectRef: ScopedProjectRef | null; + readonly projectCwd: string | null; + readonly enabled?: boolean; + readonly statuses?: boolean; +}) { + const { projectRef, projectCwd } = input; + const environmentId = projectRef?.environmentId ?? null; + const projectKey = projectRef === null ? null : scopedProjectKey(projectRef); + const offered = useWorktreePickupStore( + (state) => projectKey !== null && state.offeredProjectKeys[projectKey] === true, + ); + const hidden = useWorktreePickupStore( + (state) => projectKey !== null && state.hiddenProjectKeys[projectKey] === true, + ); + const { worktrees, loaded, statusesLoaded } = useWorktreesMadeElsewhere({ + environmentId, + cwd: projectCwd, + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + ...(input.statuses !== undefined ? { statuses: input.statuses } : {}), + }); + const threadWorktreePaths = useStore( + useShallow((state) => + selectSidebarThreadsAcrossEnvironments(state) + .filter((thread) => thread.environmentId === environmentId && thread.worktreePath !== null) + .map((thread) => thread.worktreePath as string), + ), + ); + const candidates = useMemo( + () => + projectCwd === null + ? NO_WORKTREES + : pickupCandidates({ worktrees, projectCwd, threadWorktreePaths }), + [projectCwd, threadWorktreePaths, worktrees], + ); + return { projectKey, candidates, offered, hidden, loaded, statusesLoaded }; +} diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 492e4a7ed..ebd660430 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -148,6 +148,28 @@ describe("resolveNewThreadPlacement", () => { }); }); + // A thread that does not own its worktree hands that on, not ownership. + it("continues a guest's worktree as a guest", () => { + const place = { + branch: "feature/existing", + worktreePath: "/repo/.threadlines/worktrees/existing", + envMode: "worktree", + existingWorktree: true, + }; + expect( + resolveNewThreadPlacement({ + ...base, + activeThread: { ...worktreeThread, worktreeOrigin: { kind: "existing" } }, + }), + ).toEqual(place); + expect( + resolveNewThreadPlacement({ + ...base, + activeDraftThread: { ...worktreeThread, envMode: "worktree", existingWorktree: true }, + }), + ).toEqual(place); + }); + it("starts at the project root when the active thread is somewhere else", () => { // Another project, and the same project id on another computer. for (const activeThread of [ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index e95ed11ca..013c51d68 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,5 +1,10 @@ import { scopeProjectRef } from "@threadlines/client-runtime"; -import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@threadlines/contracts"; +import type { + EnvironmentId, + ProjectId, + ScopedProjectRef, + ThreadWorktreeOrigin, +} from "@threadlines/contracts"; import type { DraftThreadEnvMode } from "../composerDraftStore"; interface ThreadContextLike { @@ -7,10 +12,14 @@ interface ThreadContextLike { projectId: ProjectId; branch: string | null; worktreePath: string | null; + /** A saved thread's: set while it is a guest in its worktree. */ + worktreeOrigin?: ThreadWorktreeOrigin | null | undefined; } interface DraftThreadContextLike extends ThreadContextLike { envMode: DraftThreadEnvMode; + /** See `DraftThreadState.existingWorktree`. */ + existingWorktree?: true; } interface NewThreadHandler { @@ -37,6 +46,8 @@ export interface NewThreadPlacement { readonly branch: string | null; readonly worktreePath: string | null; readonly envMode: DraftThreadEnvMode; + /** The worktree is not one Threadlines made for it (`DraftThreadState.existingWorktree`). */ + readonly existingWorktree?: true; } /** @@ -50,8 +61,10 @@ export interface NewThreadPlacement { * `continueActiveCheckout` is off or that checkout is known to be gone (one * deleted worktree must not spread to every thread started from it). A draft * has a checkout to continue only once it points at a worktree: "New - * worktree" picked on one draft is for that thread, not the next. The result - * is always whole, so a reused draft never keeps where it was last pointed. + * worktree" picked on one draft is for that thread, not the next. A thread + * that is a guest in its worktree hands that on: the one continuing there is + * a guest too. The result is always whole, so a reused draft never keeps + * where it was last pointed. */ export function resolveNewThreadPlacement(input: { readonly projectRef: ScopedProjectRef; @@ -85,6 +98,7 @@ export function resolveNewThreadPlacement(input: { branch: input.activeDraftThread.branch, worktreePath: input.activeDraftThread.worktreePath, envMode: input.activeDraftThread.envMode, + ...(input.activeDraftThread.existingWorktree === true ? { existingWorktree: true } : {}), }; } if (canContinue(input.activeThread)) { @@ -92,6 +106,10 @@ export function resolveNewThreadPlacement(input: { branch: input.activeThread.branch, worktreePath: input.activeThread.worktreePath, envMode: input.activeThread.worktreePath ? "worktree" : "local", + ...(input.activeThread.worktreePath !== null && + (input.activeThread.worktreeOrigin ?? null) !== null + ? { existingWorktree: true } + : {}), }; } return projectRoot; diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index bc6450aad..cdb5d5d85 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -63,6 +63,19 @@ export const gitQueryKeys = { ["git", "stashes", environmentId ?? null, cwd] as const, worktrees: (environmentId: EnvironmentId | null, cwd: string | null) => ["git", "worktrees", environmentId ?? null, cwd] as const, + worktreesMadeElsewhere: ( + environmentId: EnvironmentId | null, + cwd: string | null, + statuses: boolean, + ) => + [ + "git", + "worktrees", + environmentId ?? null, + cwd, + "elsewhere", + statuses ? "statuses" : "names", + ] as const, }; export const gitMutationKeys = { @@ -1009,6 +1022,39 @@ export function vcsListWorktreesQueryOptions(input: { }); } +/** + * The repository's worktrees that Threadlines did not make, for picking up + * (docs/design/worktree-pickup.md). Without `statuses` they are only named, + * which is one git call: the new-thread screen asks that for every project + * it shows, and keeps the answer for minutes. With `statuses` each is read + * (what changed, how far ahead), which takes seconds on a repository with + * dozens: only a list that is laid out asks for it. + */ +export function vcsWorktreesMadeElsewhereQueryOptions(input: { + environmentId: EnvironmentId | null; + cwd: string | null; + statuses: boolean; + enabled?: boolean; +}) { + return queryOptions({ + queryKey: gitQueryKeys.worktreesMadeElsewhere(input.environmentId, input.cwd, input.statuses), + queryFn: async () => { + if (!input.cwd || !input.environmentId) { + throw new Error("Worktrees are unavailable."); + } + return ensureEnvironmentApi(input.environmentId).vcs.listWorktrees({ + cwd: input.cwd, + scope: "elsewhere", + statuses: input.statuses, + }); + }, + enabled: input.environmentId !== null && input.cwd !== null && (input.enabled ?? true), + staleTime: input.statuses ? 30_000 : 5 * 60_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} + /** * Removes a worktree. The caller refreshes git views when it's done: a * cleanup removes many in a row and refreshes once at the end. diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 85f5ec76c..399fca6b4 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -261,11 +261,15 @@ function ChatThreadRouteView() { }; }, [activeProject, isGeneralChatThread, sourceControlThread, threadEffectiveCwd, threadRef]); const handleDraftSourceControlBranchChange = useCallback( - (branch: string | null, worktreePath: string | null) => { + ( + branch: string | null, + worktreePath: string | null, + guest?: { readonly existingWorktree: true }, + ) => { if (!threadRef) { return; } - setDraftThreadContext(threadRef, { branch, worktreePath }); + setDraftThreadContext(threadRef, { branch, worktreePath, ...guest }); }, [setDraftThreadContext, threadRef], ); diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index acf97ceb8..a1e220516 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -267,8 +267,12 @@ function DraftChatThreadRouteView() { openDiffOnThreadRoute(rightPanel.diffTarget ?? { diffMode: "workingTree" }); }, [activeTab, openDiffOnThreadRoute, rightPanel.diffTarget]); const handleSourceControlBranchChange = useCallback( - (branch: string | null, worktreePath: string | null) => { - setDraftThreadContext(draftId, { branch, worktreePath }); + ( + branch: string | null, + worktreePath: string | null, + guest?: { readonly existingWorktree: true }, + ) => { + setDraftThreadContext(draftId, { branch, worktreePath, ...guest }); }, [draftId, setDraftThreadContext], ); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 99d6b905b..1030d8075 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1322,7 +1322,7 @@ function sidebarThreadSummariesEqual( (left.pendingScheduleApproval ?? false) === (right.pendingScheduleApproval ?? false) && (left.queuedFollowUpCount ?? 0) === (right.queuedFollowUpCount ?? 0) && scheduledRunsEqual(left.scheduledRun, right.scheduledRun) && - (left.worktreeOrigin?.threadId ?? null) === (right.worktreeOrigin?.threadId ?? null) && + worktreeOriginsEqual(left.worktreeOrigin, right.worktreeOrigin) && outsideOriginsEqual(left.outsideOrigin, right.outsideOrigin) && remoteOriginsEqual(left.remoteOrigin, right.remoteOrigin) && remoteChildrenEqual(left.remoteChildren, right.remoteChildren) && @@ -1332,6 +1332,18 @@ function sidebarThreadSummariesEqual( ); } +function worktreeOriginsEqual( + left: ThreadWorktreeOrigin | null | undefined, + right: ThreadWorktreeOrigin | null | undefined, +): boolean { + const a = left ?? null; + const b = right ?? null; + return a === null || b === null + ? a === b + : a.kind === b.kind && + (a.kind === "thread" ? a.threadId : null) === (b.kind === "thread" ? b.threadId : null); +} + function scheduledRunsEqual( left: ThreadScheduledRun | null | undefined, right: ThreadScheduledRun | null | undefined, @@ -1420,7 +1432,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b (left.pendingChildApproval ?? false) === (right.pendingChildApproval ?? false) && (left.pendingSettingsChange ?? false) === (right.pendingSettingsChange ?? false) && scheduledRunsEqual(left.scheduledRun, right.scheduledRun) && - (left.worktreeOrigin?.threadId ?? null) === (right.worktreeOrigin?.threadId ?? null) && + worktreeOriginsEqual(left.worktreeOrigin, right.worktreeOrigin) && outsideOriginsEqual(left.outsideOrigin, right.outsideOrigin) && outsideWorkEqual(left.outsideWork, right.outsideWork) && remoteOriginsEqual(left.remoteOrigin, right.remoteOrigin) && diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index ea321d931..7bf3a6f56 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -162,6 +162,7 @@ describe("worktree cleanup selection", () => { archivedThreadTitles: [], blockingThreadTitles: [], archiveUnknown: false, + madeElsewhere: false, ...overrides, }; } @@ -183,6 +184,8 @@ describe("worktree cleanup selection", () => { expect(isWorktreeSafeToDelete(makeRow({ archiveUnknown: true }))).toBe(false); // A lock usually means an agent is still working there. expect(isWorktreeSafeToDelete(makeRow({ lockReason: "" }))).toBe(false); + // A folder Threadlines did not make is the user's to pick, however clean. + expect(isWorktreeSafeToDelete(makeRow({ madeElsewhere: true }))).toBe(false); }); // Squash merges ship a branch under new commits, and a folder that is diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 1cfc4e35d..5ae09eef6 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -102,6 +102,12 @@ export interface WorktreeCleanupRow { readonly blockingThreadTitles: readonly string[]; /** Archived threads could not be read, so one may still point here. */ readonly archiveUnknown: boolean; + /** + * Outside Threadlines' own worktrees folder: the user or another tool made + * it (docs/design/worktree-pickup.md). False when the computer's server + * cannot say, which is every worktree on one from before worktree pickup. + */ + readonly madeElsewhere: boolean; } const threadTitle = (thread: { readonly title?: string | null | undefined }): string => @@ -114,6 +120,8 @@ export function classifyWorktreesForCleanup(input: { readonly archivedThreads: readonly WorktreeUsageThread[]; /** The archived thread list failed to load. */ readonly archivedThreadsUnknown?: boolean; + /** The server says which worktrees Threadlines made (`VcsWorktreeStatus.managed`). */ + readonly managedKnown?: boolean; }): readonly WorktreeCleanupRow[] { return input.worktrees .filter((worktree) => !worktree.isRoot) @@ -140,6 +148,7 @@ export function classifyWorktreesForCleanup(input: { archivedThreadTitles: archived.map((thread) => thread.title ?? "Untitled thread"), blockingThreadTitles: live.map((thread) => threadTitle(thread)), archiveUnknown: input.archivedThreadsUnknown === true, + madeElsewhere: input.managedKnown === true && !worktree.managed, } satisfies WorktreeCleanupRow; }); } @@ -161,7 +170,8 @@ function isBranchShipped(row: WorktreeCleanupRow): boolean { * of its branch's work already on the default branch, no lock, and nothing * pointing at it. A registration whose folder is already gone holds no files * at all. These are the rows the cleanup dialog pre-checks; everything else - * the user opts into. Unknown is never safe: a status git could not read, a + * the user opts into, and a folder Threadlines did not make is always the + * user's to pick. Unknown is never safe: a status git could not read, a * commit count it could not take (a detached checkout, unrelated histories, no * resolvable default branch), or an archived thread list that did not load. */ @@ -170,7 +180,8 @@ export function isWorktreeSafeToDelete(row: WorktreeCleanupRow): boolean { row.state !== "unused" || row.archiveUnknown || row.archivedThreadTitles.length > 0 || - row.lockReason !== null + row.lockReason !== null || + (row.madeElsewhere && !row.missing) ) { return false; } @@ -194,6 +205,9 @@ export function describeWorktreeNotes( if (row.missing) { notes.push("folder already gone"); } else { + if (row.madeElsewhere) { + notes.push("made outside Threadlines"); + } if (row.refName === null) { notes.push("detached checkout"); } diff --git a/apps/web/src/worktreeOrigin.ts b/apps/web/src/worktreeOrigin.ts new file mode 100644 index 000000000..a7aef8212 --- /dev/null +++ b/apps/web/src/worktreeOrigin.ts @@ -0,0 +1,11 @@ +import type { ThreadId, ThreadWorktreeOrigin } from "@threadlines/contracts"; + +/** + * The thread whose worktree a guest thread works in, when it is another + * thread's (ThreadWorktreeOrigin `thread`). Null for a thread in its own + * worktree, and for one the user pointed at a worktree the repository + * already had (`existing`): that one has no other thread to name. + */ +export function lendingThreadId(origin: ThreadWorktreeOrigin | null | undefined): ThreadId | null { + return origin?.kind === "thread" ? origin.threadId : null; +} diff --git a/apps/web/src/worktreePickup.test.ts b/apps/web/src/worktreePickup.test.ts new file mode 100644 index 000000000..faa015a6b --- /dev/null +++ b/apps/web/src/worktreePickup.test.ts @@ -0,0 +1,91 @@ +import type { VcsWorktreeStatus } from "@threadlines/contracts"; +import { describe, expect, it } from "vitest"; + +import { + isSettledWorktree, + pickupCandidates, + threadTitleFromWorktree, + worktreePathLabel, +} from "./worktreePickup"; + +function worktree(overrides: Partial = {}): VcsWorktreeStatus { + return { + path: "/code/shop-checkout-retry", + refName: "fix/checkout-retry", + isRoot: false, + dirty: false, + dirtyUnknown: false, + unmergedCommitCount: 2, + unrelatedHistory: false, + mergedByContent: false, + missing: false, + lockReason: null, + managed: false, + changedFileCount: 0, + lastCommitAt: null, + ...overrides, + }; +} + +describe("pickupCandidates", () => { + it("offers the worktrees made elsewhere that are on disk and that no thread works in", () => { + const free = worktree(); + const candidates = pickupCandidates({ + worktrees: [ + free, + worktree({ path: "/code/shop-taken", refName: "feature/taken" }), + worktree({ path: "/code/shop-gone", missing: true }), + worktree({ path: "/code/shop", isRoot: true }), + worktree({ path: "/home/.threadlines/worktrees/shop/abc", managed: true }), + // A project that sits in a linked worktree is not its own pickup. + worktree({ path: "/code/shop-project" }), + ], + projectCwd: "/code/shop-project/apps/web", + threadWorktreePaths: ["/code/shop-taken"], + }); + expect(candidates).toEqual([free]); + }); +}); + +describe("isSettledWorktree", () => { + it("counts a worktree with nothing left to ship, and never one with uncommitted changes", () => { + expect(isSettledWorktree(worktree())).toBe(false); + expect(isSettledWorktree(worktree({ unmergedCommitCount: 0 }))).toBe(true); + expect(isSettledWorktree(worktree({ mergedByContent: true }))).toBe(true); + expect( + isSettledWorktree(worktree({ mergedByContent: true, dirty: true, changedFileCount: 3 })), + ).toBe(false); + // Git could not say how far ahead it is: that is not "nothing". + expect(isSettledWorktree(worktree({ unmergedCommitCount: null }))).toBe(false); + }); +}); + +describe("threadTitleFromWorktree", () => { + it("reads a branch as a title", () => { + const title = (refName: string | null, path = "/code/shop-checkout-retry") => + threadTitleFromWorktree({ refName, path }); + expect(title("fix/checkout-retry")).toBe("Checkout retry"); + expect(title("will/feat/cart_totals")).toBe("Cart totals"); + expect(title("main")).toBe("Main"); + // A last part that says nothing alone keeps the parts before it. + expect(title("release/1.2")).toBe("Release 1.2"); + // Detached: the folder names it. + expect(title(null)).toBe("Shop checkout retry"); + expect(title(null, "C:\\code\\shop-hotfix\\")).toBe("Shop hotfix"); + }); +}); + +describe("worktreePathLabel", () => { + it("names a worktree by where it is from the project, or whole when it is far off", () => { + expect(worktreePathLabel("/code/shop-checkout-retry", "/code/shop")).toBe( + "../shop-checkout-retry", + ); + expect(worktreePathLabel("/code/shop/.claude/worktrees/retry/", "/code/shop")).toBe( + ".claude/worktrees/retry", + ); + expect(worktreePathLabel("C:\\Code\\shop-retry", "c:\\code\\shop")).toBe("../shop-retry"); + expect(worktreePathLabel("/work/conductor/shop/tokyo", "/code/shop")).toBe( + "/work/conductor/shop/tokyo", + ); + }); +}); diff --git a/apps/web/src/worktreePickup.ts b/apps/web/src/worktreePickup.ts new file mode 100644 index 000000000..40a76e0e9 --- /dev/null +++ b/apps/web/src/worktreePickup.ts @@ -0,0 +1,194 @@ +/** + * Picking up worktrees a repository already had, made outside Threadlines: + * which ones are on offer, what a thread made for one is called, and what + * this device remembers about the offer (docs/design/worktree-pickup.md). + */ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + type EnvironmentId, + type ModelSelection, + type ProjectId, + type RuntimeMode, + type VcsWorktreeStatus, +} from "@threadlines/contracts"; +import { + areFilesystemPathsEqual, + isFilesystemPathWithin, + isWindowsAbsolutePath, + shortenHomePath, +} from "@threadlines/shared/path"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { ensureEnvironmentApi } from "./environmentApi"; +import { resolveStorage } from "./lib/storage"; +import { newCommandId, newThreadId } from "./lib/utils"; + +/** + * The worktrees a thread could be made for: there on disk, not the one the + * project's own folder is in, and with no thread working in them yet. + */ +export function pickupCandidates(input: { + readonly worktrees: ReadonlyArray; + readonly projectCwd: string; + /** Where the computer's threads work, saved and drafted alike. */ + readonly threadWorktreePaths: ReadonlyArray; +}): ReadonlyArray { + return input.worktrees.filter( + (worktree) => + !worktree.missing && + !worktree.isRoot && + !worktree.managed && + !isFilesystemPathWithin(input.projectCwd, worktree.path) && + !input.threadWorktreePaths.some((path) => areFilesystemPathsEqual(path, worktree.path)), + ); +} + +/** + * Nothing in the worktree is waiting to ship: no uncommitted changes, and its + * branch adds nothing the default branch lacks. The list starts these + * unticked. + */ +export function isSettledWorktree(worktree: VcsWorktreeStatus): boolean { + return !worktree.dirty && (worktree.mergedByContent || worktree.unmergedCommitCount === 0); +} + +/** + * Where a worktree is, as a row says it: beside the project it reads + * "../shop-checkout-retry", which is what tells two apart; anywhere else it + * is the whole path, with the home folder as "~". + */ +export function worktreePathLabel(worktreePath: string, projectCwd: string): string { + const parts = (value: string) => value.split(/[\\/]+/).filter((part) => part.length > 0); + const worktree = parts(worktreePath); + const project = parts(projectCwd); + const same = isWindowsAbsolutePath(worktreePath) + ? (left: string, right: string) => left.toLowerCase() === right.toLowerCase() + : (left: string, right: string) => left === right; + let shared = 0; + while ( + shared < worktree.length && + shared < project.length && + same(worktree[shared]!, project[shared]!) + ) { + shared += 1; + } + const up = project.length - shared; + const down = worktree.slice(shared); + // Beside the project or a little way into it; farther off reads better whole. + if (shared === 0 || up > 1 || down.length === 0 || down.length > 3) { + return shortenHomePath(worktreePath); + } + return [...Array.from({ length: up }, () => ".."), ...down].join("/"); +} + +function lastPathSegment(value: string): string { + const segments = value.split(/[\\/]/).filter((segment) => segment.length > 0); + return segments[segments.length - 1] ?? value; +} + +function sentenceFromSlug(slug: string): string { + const words = slug.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim(); + return words.length === 0 ? "" : words[0]!.toUpperCase() + words.slice(1); +} + +/** + * A thread's title from the branch its worktree is on: "fix/checkout-retry" + * reads "Checkout retry". A last part that says nothing alone (a version, a + * number) keeps the parts before it; a detached worktree is named for its + * folder. + */ +export function threadTitleFromWorktree(worktree: Pick) { + if (worktree.refName === null) { + return sentenceFromSlug(lastPathSegment(worktree.path)) || "Worktree"; + } + const lastPart = lastPathSegment(worktree.refName); + const title = /[a-z]/i.test(lastPart) + ? sentenceFromSlug(lastPart) + : sentenceFromSlug(worktree.refName.replace(/[\\/]+/g, " ")); + return title || worktree.refName; +} + +/** + * Makes a thread for each worktree, a guest there (ThreadWorktreeOrigin + * `existing`), named for its branch. No turn starts: each waits in the + * sidebar for its first message. One that fails stops the rest, so the error + * names what is still to add. + */ +export async function addWorktreeThreads(input: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly worktrees: ReadonlyArray; + readonly modelSelection: ModelSelection; + readonly runtimeMode: RuntimeMode; +}): Promise { + const api = ensureEnvironmentApi(input.environmentId); + for (const worktree of input.worktrees) { + await api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: newThreadId(), + projectId: input.projectId, + title: threadTitleFromWorktree(worktree), + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: worktree.refName, + worktreePath: worktree.path, + worktreeOrigin: { kind: "existing" }, + createdAt: new Date().toISOString(), + }); + } +} + +interface WorktreePickupState { + /** + * Projects whose worktrees are laid out for picking: set when a repository + * is added or the user asks for the list, cleared by an answer either way. + */ + readonly offeredProjectKeys: Readonly>; + /** Projects whose worktree count the user hid from the new-thread screen. */ + readonly hiddenProjectKeys: Readonly>; + /** Lays the list out again, and brings a hidden count back with it. */ + readonly offer: (projectKey: string) => void; + readonly closeOffer: (projectKey: string) => void; + readonly hide: (projectKey: string) => void; +} + +function without(keys: Readonly>, key: string) { + if (!(key in keys)) return keys; + const { [key]: _removed, ...rest } = keys; + return rest; +} + +/** Per device, like the rest of how a screen is arranged. */ +export const useWorktreePickupStore = create()( + persist( + (set) => ({ + offeredProjectKeys: {}, + hiddenProjectKeys: {}, + offer: (projectKey) => + set((state) => ({ + offeredProjectKeys: { ...state.offeredProjectKeys, [projectKey]: true }, + hiddenProjectKeys: without(state.hiddenProjectKeys, projectKey), + })), + closeOffer: (projectKey) => + set((state) => ({ offeredProjectKeys: without(state.offeredProjectKeys, projectKey) })), + hide: (projectKey) => + set((state) => ({ + offeredProjectKeys: without(state.offeredProjectKeys, projectKey), + hiddenProjectKeys: { ...state.hiddenProjectKeys, [projectKey]: true }, + })), + }), + { + name: "threadlines:worktree-pickup:v1", + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ + offeredProjectKeys: state.offeredProjectKeys, + hiddenProjectKeys: state.hiddenProjectKeys, + }), + }, + ), +); diff --git a/docs/design/worktree-pickup.md b/docs/design/worktree-pickup.md new file mode 100644 index 000000000..69879dfaf --- /dev/null +++ b/docs/design/worktree-pickup.md @@ -0,0 +1,64 @@ +# Worktree pickup + +Status: built Nov 2026. + +## Goal + +A repository often has worktrees Threadlines did not make: `git worktree add` by hand, or another app that keeps one per task. Someone who adds that repository as a project should be able to carry on in those worktrees without typing paths, and without Threadlines treating the folders as its own. The UI never names another app. + +## What the user sees + +1. **Right after adding the repository.** The new-thread screen lays the worktrees out under the message box: a checkbox each, the branch, where it is, what is in it (changed files, commits ahead, age). Ones with nothing left to ship start unticked. "Add N threads" makes a thread per ticked worktree; "Not now" folds the list away. Either answer ends the offer. +2. **Added threads.** Each lands in the sidebar named from its branch ("fix/checkout-retry" reads "Checkout retry"). No turn starts. Opened before its first message, it says where it works and that deleting it leaves the folder. +3. **Every later visit.** Only a count, beside the recent threads' count ("4 worktrees"). Opening it lists the worktrees with "Work here" (points the draft on screen at it), "Add as threads…" (lays the list out again) and "Hide for this project". +4. **The Workspace picker** under the message box lists them under "Existing worktree", on desktop and phone, while the workspace can still be chosen: a draft, or a saved thread in the project folder that has not started. +5. **The command palette** has "Add existing worktrees as threads…" when the open project has any. It lays the list out and brings a hidden count back, which is the way out of "Hide for this project". + +Which worktrees count: registered with git, present on disk, outside Threadlines' own worktrees folder, not the main checkout, not the one the project's folder sits in, and with no thread on this computer pointed at them. + +## A guest in the worktree + +A thread in one of these does not own it. That is `ThreadWorktreeOrigin` `{ kind: "existing" }` on the thread, the same marker a thread lent another thread's worktree carries (`kind: "thread"`, child-threads.md "Workspaces"). Everything that already asked "is this thread a guest?" covers it: + +- the first turn never renames the branch; +- no setup script runs; +- deleting the thread never offers to remove the folder, and cleanup never pre-ticks it; +- undoing a turn only undoes the thread's own edits, never a whole-folder restore; +- a thread that continues in a guest's worktree ("New thread" from it, a plan implemented in a new thread) is a guest too. + +The marker goes when the thread moves to another checkout. + +How a thread gets it: + +- `thread.create` with `worktreeOrigin: { kind: "existing" }` (the list's "Add N threads", and a draft's first send). The decider refuses it without a worktree path, and still refuses `kind: "thread"` from a client. +- `thread.checkout.select` with `existingWorktree: true` (a saved thread picking one in the Workspace picker). + +A draft remembers it as `existingWorktree` beside its `worktreePath`; pointing the draft anywhere else drops it. + +The branch picker under the message box and the Source panel reach these worktrees too (a branch checked out in one is listed with its worktree; the cleanup list can move a thread into one to look at it). Both ask for the same names list (`useWorktreesMadeElsewhere`) and send the marker when the worktree they point a thread at is one of them. A branch made or switched inside the worktree a guest is already in leaves the marker alone. + +Not covered: a thread pointed at one of these worktrees before this shipped, or while the computer's server is older, has no marker and is treated as the worktree's owner, as it always was. + +## Listing + +`vcs.listWorktrees` gained two inputs, both ignored by an older server: + +- `scope: "elsewhere"` leaves out the main checkout and everything under Threadlines' worktrees folder (compared by real path) before any per-worktree work. +- `statuses: false` names the worktrees without reading them. Reading one costs a `git status` there; on a repository with a few dozen worktrees that came to about seven seconds, every time. The count, the Workspace picker and the palette ask for names only (one `git worktree list`), kept five minutes. The laid-out list and the opened rows ask for statuses when they are shown; the names stand in until the answer comes, with "Checking" where the facts go and "Add" held back, since which rows start ticked is not known yet. + +Each row also carries `managed`, `changedFileCount` and `lastCommitAt`, with decode defaults for an older server. + +## Older servers and clients + +The server says `worktreePickup` in its capabilities. Without it the client never asks for the list and never sends the marker, so nothing shows, and a list kept from before the server went back a version is dropped. + +A client built between the shared-checkout work (#911, Nov 14 2026) and this one decodes `worktreeOrigin` as the one-member struct and rejects `kind: "existing"`, which breaks its thread list once any picked-up thread exists on a newer server it is connected to. No stable release has that schema (0.10.1 predates #911 and ignores the field), and a server serves its own client, so this needs a nightly from that window linked to a newer computer. Accepted; the alternative was a second field every "is it a guest?" check would have to read. + +## What this device remembers + +Per device, in local storage (`worktreePickup.ts`): which projects have the list laid out, and which have the count hidden. Adding a project sets the first; an offer that turns out empty is closed, so a repository added without worktrees does not lay a list out months later. + +## Not built + +- Continuing another tool's conversations from its own files. Separate question, waiting on demand. +- Removing or pruning these worktrees from Threadlines. They are the user's. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 55a349b9d..f8c291d2c 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -148,6 +148,14 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ * merge switch by number only for a pull request already linked. */ pullRequestSwitchesByNumber: Schema.optionalKey(Schema.Boolean), + /** + * The server lists the worktrees a repository has that Threadlines did not + * make (`vcs.listWorktrees` with `scope: "elsewhere"`) and records a thread + * started in one as a guest there (ThreadWorktreeOrigin `existing`), so it + * is never renamed, set up or offered for deletion. Older servers omit it; + * clients then don't offer to pick worktrees up. + */ + worktreePickup: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 21991e511..06ce34e4d 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -157,6 +157,14 @@ export const VcsWorktreeStatus = Schema.Struct({ * works in it. */ lockReason: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + /** Inside Threadlines' own worktrees folder: one it made for a thread. */ + managed: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** How many paths `git status` lists as changed; null when it could not be read. */ + changedFileCount: Schema.NullOr(NonNegativeInt).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + /** When the commit the checkout is on was made; null when it has none. */ + lastCommitAt: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed(null))), }); export type VcsWorktreeStatus = typeof VcsWorktreeStatus.Type; const GitResolvedPullRequest = Schema.Struct({ @@ -414,6 +422,20 @@ export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; export const VcsListWorktreesInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + /** + * `elsewhere`: only the checkouts Threadlines did not make (not the main + * one, and none in its own worktrees folder), the ones a user can pick up. + * Left out: every checkout. An older server ignores it and lists them all. + */ + scope: Schema.optional(Schema.Literal("elsewhere")), + /** + * False lists the checkouts without reading what is in them: only `path`, + * `refName`, `isRoot`, `missing`, `lockReason` and `managed` are real, and + * the rest say "unknown" (`dirtyUnknown`, nulls). Reading a checkout costs + * a `git status` there, which adds up to seconds across a few dozen; a + * count or a menu needs none of it. An older server reads them anyway. + */ + statuses: Schema.optional(Schema.Boolean), }); export type VcsListWorktreesInput = typeof VcsListWorktreesInput.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 33ceaf9e2..d38632f4a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -668,18 +668,26 @@ export type ThreadScheduledRun = typeof ThreadScheduledRun.Type; /** * Where a thread's worktree came from, when Threadlines did not make it for - * that thread: another thread's, which the agent steering both pointed this - * one at (thread_start and thread_send `checkout`, docs/design/child-threads.md, - * "Workspaces"). The thread works there without owning it: its first turn - * never renames the branch, the project's setup script is not run for it, and - * deleting it never offers to remove the folder. It describes the current - * `worktreePath`, and goes when the thread moves to another checkout. + * that thread. The thread works there without owning it: its first turn + * never renames the branch, the project's setup script is not run for it, + * deleting it never offers to remove the folder, and a revert in it only + * undoes its own edits. It describes the current `worktreePath`, and goes + * when the thread moves to another checkout. + * + * - `thread`: another thread's, which the agent steering both pointed this + * one at (thread_start and thread_send `checkout`, + * docs/design/child-threads.md, "Workspaces"). + * - `existing`: one the repository already had, made outside Threadlines, + * that the user picked up (docs/design/worktree-pickup.md). */ -export const ThreadWorktreeOrigin = Schema.Struct({ - kind: Schema.Literal("thread"), - /** The thread whose worktree it is. It may be archived or deleted since. */ - threadId: ThreadId, -}); +export const ThreadWorktreeOrigin = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("thread"), + /** The thread whose worktree it is. It may be archived or deleted since. */ + threadId: ThreadId, + }), + Schema.Struct({ kind: Schema.Literal("existing") }), +]); export type ThreadWorktreeOrigin = typeof ThreadWorktreeOrigin.Type; /** @@ -2976,7 +2984,10 @@ const ThreadCreateLineageFields = { scheduledRun: Schema.optional(ThreadScheduledRun), /** Started by an outside agent: see OutsideAgentRef. Only `/mcp/connect` sets it. */ outsideOrigin: Schema.optional(OutsideAgentRef), - /** Started in another thread's worktree: see ThreadWorktreeOrigin. Only a child's setup sets it. */ + /** + * Started in a worktree it does not own: see ThreadWorktreeOrigin. `thread` + * is the server's alone (a child's setup); a client may say `existing`. + */ worktreeOrigin: Schema.optional(ThreadWorktreeOrigin), }; // The normalizer refuses these on commands that arrive from a client. @@ -3261,6 +3272,12 @@ const ThreadCheckoutSelectCommand = Schema.Struct({ threadId: ThreadId, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + /** + * The worktree was made outside Threadlines (ThreadWorktreeOrigin + * `existing`): the thread works there as a guest. Ignored for the project + * folder, and by a server older than worktree pickup. + */ + existingWorktree: Schema.optional(Schema.Boolean), }); const ThreadRuntimeModeSetCommand = Schema.Struct({