Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/environment/Layers/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function
deviceNames: true,
draftGoalSetup: true,
pullRequestSwitchesByNumber: true,
worktreePickup: true,
},
} satisfies Omit<ExecutionEnvironmentDescriptor, "label" | "systemLabel">;

Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/guide/topics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,26 @@ export const GUIDE_TOPICS: ReadonlyArray<GuideTopic> = [
{
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.
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/orchestration/childThreadDecisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
}

Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/orchestration/decider.childThreads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrchestrationCommand, { type: "thread.create" }>> = {},
): Extract<OrchestrationCommand, { type: "thread.create" }> => ({
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<OrchestrationCommand, { type: "thread.checkout.select" }> => ({
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();
Expand Down
25 changes: 18 additions & 7 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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({
Expand Down
64 changes: 63 additions & 1 deletion apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);

Expand Down Expand Up @@ -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.
Expand Down
82 changes: 71 additions & 11 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | null> =>
const readWorktreeChangedFileCount = (cwd: string): Effect.Effect<number | null> =>
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<string | null> =>
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)),
);

Expand Down Expand Up @@ -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<string, string>();

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)));
Expand All @@ -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(
Expand All @@ -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* (
Expand Down
Loading
Loading