Skip to content

Commit 13f7ab6

Browse files
authored
Start a task from an existing task-less worktree (#3528)
1 parent 3d788e3 commit 13f7ab6

20 files changed

Lines changed: 713 additions & 8 deletions

packages/core/src/task-detail/taskCreationSaga.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const mockHost = vi.hoisted(() => ({
3636
}));
3737

3838
import { TaskCreationSaga } from "./taskCreationSaga";
39+
import { buildWorktreeAdoptionInput } from "./taskInput";
3940

4041
const host = mockHost as unknown as ITaskCreationHost;
4142

@@ -1026,6 +1027,51 @@ describe("TaskCreationSaga", () => {
10261027
);
10271028
});
10281029

1030+
it("adopts an existing worktree into a promptless task (worktree adoption)", async () => {
1031+
const createTaskMock = vi.fn().mockResolvedValue(createTask());
1032+
mockHost.addFolder.mockResolvedValue({ id: "folder-1", path: "/repo" });
1033+
mockHost.detectRepo.mockResolvedValue(null);
1034+
mockHost.createWorkspace.mockResolvedValue({
1035+
taskId: "task-123",
1036+
mode: "worktree",
1037+
worktree: {
1038+
worktreePath: "/wt/orphan",
1039+
worktreeName: "orphan",
1040+
branchName: "feature/orphan",
1041+
baseBranch: "",
1042+
createdAt: "",
1043+
},
1044+
branchName: "feature/orphan",
1045+
linkedBranch: null,
1046+
});
1047+
1048+
const saga = makeSaga({ createTask: createTaskMock });
1049+
1050+
const result = await saga.run(
1051+
buildWorktreeAdoptionInput({
1052+
repoPath: "/repo",
1053+
branch: "feature/orphan",
1054+
}),
1055+
);
1056+
1057+
expect(result.success).toBe(true);
1058+
// The branch doubles as the task description so the task is named after it.
1059+
expect(createTaskMock).toHaveBeenCalledWith(
1060+
expect.objectContaining({ description: "feature/orphan" }),
1061+
);
1062+
expect(mockHost.createWorkspace).toHaveBeenCalledWith(
1063+
expect.objectContaining({
1064+
branch: "feature/orphan",
1065+
reuseExistingWorktree: true,
1066+
}),
1067+
);
1068+
// No typed prompt: the agent session starts idle in the adopted worktree.
1069+
const connectParams = vi.mocked(sessionService.connectToTask).mock
1070+
.calls[0][0];
1071+
expect(connectParams.repoPath).toBe("/wt/orphan");
1072+
expect(connectParams.initialPrompt).toBeUndefined();
1073+
});
1074+
10291075
it("creates the task without a repository when repo detection fails", async () => {
10301076
const createTaskMock = vi.fn().mockResolvedValue(createTask());
10311077
mockHost.addFolder.mockResolvedValue({ id: "folder-1", path: "/repo" });

packages/core/src/task-detail/taskInput.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { prepareTaskInput } from "./taskInput";
2+
import { buildWorktreeAdoptionInput, prepareTaskInput } from "./taskInput";
33

44
describe("prepareTaskInput", () => {
55
// The isCloud guard on customInstructions is the only thing preventing
@@ -28,3 +28,23 @@ describe("prepareTaskInput", () => {
2828
expect(input.customInstructions).toBeUndefined();
2929
});
3030
});
31+
32+
describe("buildWorktreeAdoptionInput", () => {
33+
it("builds a promptless worktree input that adopts the branch's worktree", () => {
34+
const input = buildWorktreeAdoptionInput({
35+
repoPath: "/repo",
36+
branch: "feature/orphan",
37+
});
38+
39+
expect(input).toEqual({
40+
taskDescription: "feature/orphan",
41+
repoPath: "/repo",
42+
workspaceMode: "worktree",
43+
branch: "feature/orphan",
44+
reuseExistingWorktree: true,
45+
});
46+
// No content: the saga must not build an initial prompt, so the agent
47+
// session starts idle in the adopted worktree.
48+
expect(input.content).toBeUndefined();
49+
});
50+
});

packages/core/src/task-detail/taskInput.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,25 @@ export function prepareTaskInput(
8282
};
8383
}
8484

85+
/**
86+
* Input for starting a task from an existing task-less worktree (the sidebar's
87+
* one-click adoption). No content: the agent session starts idle and the user
88+
* types the first message in the opened chat. The branch doubles as the task
89+
* description so the task is named after it.
90+
*/
91+
export function buildWorktreeAdoptionInput(options: {
92+
repoPath: string;
93+
branch: string;
94+
}): TaskCreationInput {
95+
return {
96+
taskDescription: options.branch,
97+
repoPath: options.repoPath,
98+
workspaceMode: "worktree",
99+
branch: options.branch,
100+
reuseExistingWorktree: true,
101+
};
102+
}
103+
85104
const ERROR_TITLES: Record<string, string> = {
86105
repo_detection: "Failed to detect repository",
87106
task_creation: "Failed to create task",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { SessionService } from "@posthog/core/sessions/sessionService";
2+
import type { RootLogger } from "@posthog/di/logger";
3+
import { describe, expect, it, vi } from "vitest";
4+
import type { TaskCreationEffects } from "./taskCreationEffects";
5+
import type { ITaskCreationHost } from "./taskCreationHost";
6+
import { buildWorktreeAdoptionInput } from "./taskInput";
7+
import { TaskService } from "./taskService";
8+
9+
const scopedLog = {
10+
debug: vi.fn(),
11+
info: vi.fn(),
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
};
15+
const rootLogger = {
16+
...scopedLog,
17+
scope: () => scopedLog,
18+
} as unknown as RootLogger;
19+
20+
function makeService(): TaskService {
21+
const host = {
22+
// The API client's createTask rejects so tests can observe that an input
23+
// made it past validation (failedStep lands on task_creation, not
24+
// validation) without faking the whole saga.
25+
getAuthenticatedClient: vi.fn(async () => ({
26+
createTask: vi.fn().mockRejectedValue(new Error("api down")),
27+
deleteTask: vi.fn(),
28+
getTask: vi.fn(),
29+
createTaskRun: vi.fn(),
30+
startTaskRun: vi.fn(),
31+
sendRunCommand: vi.fn(),
32+
updateTask: vi.fn(),
33+
})),
34+
detectRepo: vi.fn(async () => null),
35+
getFolders: vi.fn(async () => []),
36+
addFolder: vi.fn(async () => ({ id: "folder-1", path: "/repo" })),
37+
track: vi.fn(),
38+
} as unknown as ITaskCreationHost;
39+
const sessionService = {
40+
markTaskCreationInFlight: vi.fn(),
41+
connectToTask: vi.fn(),
42+
disconnectFromTask: vi.fn(),
43+
} as unknown as SessionService;
44+
const effects = {
45+
onWorkspaceCreated: vi.fn(),
46+
onCreateSuccess: vi.fn(),
47+
} as unknown as TaskCreationEffects;
48+
return new TaskService(host, sessionService, effects, rootLogger);
49+
}
50+
51+
describe("TaskService.createTask validation", () => {
52+
it("rejects an input with neither content nor a taskDescription", async () => {
53+
const result = await makeService().createTask({
54+
content: " ",
55+
repoPath: "/repo",
56+
workspaceMode: "worktree",
57+
});
58+
59+
expect(result.success).toBe(false);
60+
if (result.success) throw new Error("expected validation failure");
61+
expect(result.failedStep).toBe("validation");
62+
});
63+
64+
it("accepts a promptless worktree-adoption input", async () => {
65+
const result = await makeService().createTask(
66+
buildWorktreeAdoptionInput({
67+
repoPath: "/repo",
68+
branch: "feature/orphan",
69+
}),
70+
);
71+
72+
// The stubbed API call fails, proving the input got past validation.
73+
expect(result.success).toBe(false);
74+
if (result.success) throw new Error("expected task_creation failure");
75+
expect(result.failedStep).toBe("task_creation");
76+
});
77+
});

packages/core/src/task-detail/taskService.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ export class TaskService {
6161
hasRepo: !!input.repository,
6262
});
6363

64-
// Imported Claude Code sessions carry a transcript, not a typed prompt, so
65-
// they supply a taskDescription instead of content.
64+
// Promptless flows (imported Claude Code sessions, worktree adoption)
65+
// synthesize a taskDescription instead of typed content; either one names
66+
// the task, so either satisfies validation.
6667
const hasDescription =
67-
!!input.content?.trim() ||
68-
(!!input.importedClaudeSession && !!input.taskDescription?.trim());
68+
!!input.content?.trim() || !!input.taskDescription?.trim();
6969
if (!hasDescription) {
7070
return {
7171
success: false,

packages/host-router/src/routers/workspace.router.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
getWorktreeTasksInput,
2929
getWorktreeTasksOutput,
3030
linkBranchInput,
31+
listAdoptableWorktreesInput,
32+
listAdoptableWorktreesOutput,
3133
listGitWorktreesInput,
3234
listGitWorktreesOutput,
3335
listRepoCheckoutsInput,
@@ -164,6 +166,13 @@ export const workspaceRouter = router({
164166
getService(ctx.container).listRepoCheckouts(input.repoPath),
165167
),
166168

169+
listAdoptableWorktrees: publicProcedure
170+
.input(listAdoptableWorktreesInput)
171+
.output(listAdoptableWorktreesOutput)
172+
.query(({ ctx, input }) =>
173+
getService(ctx.container).listAdoptableWorktrees(input.mainRepoPath),
174+
),
175+
167176
getWorktreeSize: publicProcedure
168177
.input(getWorktreeSizeInput)
169178
.output(getWorktreeSizeOutput)

packages/shared/src/analytics-events.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ export interface PromptHistorySelectedProperties {
1616

1717
type ExecutionType = "cloud" | "local";
1818
export type RepositoryProvider = "github" | "gitlab" | "local" | "none";
19-
type TaskCreatedFrom = "cli" | "command-menu" | "home-quick-action";
19+
type TaskCreatedFrom =
20+
| "cli"
21+
| "command-menu"
22+
| "home-quick-action"
23+
| "sidebar-worktree";
2024
type RepositorySelectSource = "task-creation" | "task-detail";
2125
type GitActionType =
2226
| "push"

packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useDeleteTask } from "../../../tasks/useTaskCrudMutations";
1616
import { useTasks } from "../../../tasks/useTasks";
1717
import { WORKSPACE_QUERY_KEY } from "../../../workspace/identifiers";
1818
import { SettingRow } from "../../SettingRow";
19+
import { useSettingsStore } from "../../settingsStore";
1920
import { WorktreeGroupSection } from "./WorktreeGroupSection";
2021

2122
const log = logger.scope("worktrees-settings");
@@ -25,6 +26,12 @@ export function WorktreesSettings() {
2526
const trpc = useHostTRPC();
2627
const hostClient = useHostTRPCClient();
2728
const { settings, updateSettings } = useSuspensionSettings();
29+
const showSidebarWorktrees = useSettingsStore(
30+
(state) => state.showSidebarWorktrees,
31+
);
32+
const setShowSidebarWorktrees = useSettingsStore(
33+
(state) => state.setShowSidebarWorktrees,
34+
);
2835
const { mutateAsync: deleteTask } = useDeleteTask();
2936
const [deletingWorktrees, setDeletingWorktrees] = useState<Set<string>>(
3037
new Set(),
@@ -133,6 +140,16 @@ export function WorktreesSettings() {
133140
return (
134141
<Flex direction="column" gap="5">
135142
<Flex direction="column">
143+
<SettingRow
144+
label="Show worktrees in sidebar"
145+
description="List worktrees that have no task under each repo in the sidebar, so you can start a task in one with a click"
146+
>
147+
<Switch
148+
checked={showSidebarWorktrees}
149+
onCheckedChange={setShowSidebarWorktrees}
150+
size="1"
151+
/>
152+
</SettingRow>
136153
<SettingRow
137154
label="Automatically suspend stale worktrees"
138155
description="Suspend stale worktrees to save disk space. Suspended worktrees can be restored at any time. Only disable if you prefer to manage worktrees manually."

packages/ui/src/features/settings/settingsStore.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ describe("feature settingsStore cloud selections", () => {
219219
["debugLogsCloudRuns", false, true],
220220
["slotMachineMode", false, true],
221221
["dismissibleUpdateBanners", false, true],
222+
["showSidebarWorktrees", false, true],
222223
] as const)("rehydrates %s", async (field, initial, persisted) => {
223224
getItem.mockResolvedValue(
224225
JSON.stringify({ state: { [field]: persisted }, version: 0 }),

packages/ui/src/features/settings/settingsStore.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,12 @@ interface SettingsStore {
230230
conversationCollapseMode: CollapseMode;
231231
setConversationCollapseMode: (mode: CollapseMode) => void;
232232

233+
// Sidebar
234+
// Shows a per-repo "Worktrees" dropdown of task-less worktrees a click can
235+
// start a task in. Opt-in: off by default to keep the sidebar uncluttered.
236+
showSidebarWorktrees: boolean;
237+
setShowSidebarWorktrees: (enabled: boolean) => void;
238+
233239
// Experimental / misc
234240
hedgehogMode: boolean;
235241
slotMachineMode: boolean;
@@ -447,6 +453,11 @@ export const useSettingsStore = create<SettingsStore>()(
447453
setConversationCollapseMode: (mode) =>
448454
set({ conversationCollapseMode: mode }),
449455

456+
// Sidebar
457+
showSidebarWorktrees: false,
458+
setShowSidebarWorktrees: (enabled) =>
459+
set({ showSidebarWorktrees: enabled }),
460+
450461
// Experimental / misc
451462
hedgehogMode: false,
452463
slotMachineMode: false,
@@ -564,6 +575,9 @@ export const useSettingsStore = create<SettingsStore>()(
564575
// Conversation thread (new-thread)
565576
conversationCollapseMode: state.conversationCollapseMode,
566577

578+
// Sidebar
579+
showSidebarWorktrees: state.showSidebarWorktrees,
580+
567581
// Experimental / misc
568582
hedgehogMode: state.hedgehogMode,
569583
slotMachineMode: state.slotMachineMode,

0 commit comments

Comments
 (0)