Skip to content

Commit b7365ea

Browse files
committed
Add sidebar section to start a task from a task-less worktree
Each repo group in the sidebar gets a nested "Worktrees (N)" dropdown listing linked worktrees no task uses. Clicking one creates a promptless task named after the branch, adopts the worktree via the existing reuseExistingWorktree flow, and opens the task's chat + shell with an idle agent session. - workspace-server: listAdoptableWorktrees filters linked worktrees to branch-bearing, task-less ones, excluding registered folders and the local-backgrounding stash worktree - core: buildWorktreeAdoptionInput + worktreeAdoption input flag so a synthesized taskDescription passes createTask validation without content (no initial agent prompt) - ui: GroupWorktreesSection nested under each repo group, with useAdoptableWorktrees / useStartTaskFromWorktree hooks Generated-By: PostHog Code Task-Id: 24032eac-82cc-4c5f-a7e9-30ed9278a6e1
1 parent 5298cb9 commit b7365ea

16 files changed

Lines changed: 459 additions & 5 deletions

File tree

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: 22 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,24 @@ 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+
worktreeAdoption: true,
46+
});
47+
// No content: the saga must not build an initial prompt, so the agent
48+
// session starts idle in the adopted worktree.
49+
expect(input.content).toBeUndefined();
50+
});
51+
});

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ 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+
worktreeAdoption: true,
102+
};
103+
}
104+
85105
const ERROR_TITLES: Record<string, string> = {
86106
repo_detection: "Failed to detect repository",
87107
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 a promptless input without the adoption or import markers", async () => {
53+
const result = await makeService().createTask({
54+
taskDescription: "feature/orphan",
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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,13 @@ export class TaskService {
6262
});
6363

6464
// Imported Claude Code sessions carry a transcript, not a typed prompt, so
65-
// they supply a taskDescription instead of content.
65+
// they supply a taskDescription instead of content. Worktree-adoption tasks
66+
// have no prompt either — the user types their first message in the opened
67+
// chat — so they too rely on a synthesized taskDescription.
6668
const hasDescription =
6769
!!input.content?.trim() ||
68-
(!!input.importedClaudeSession && !!input.taskDescription?.trim());
70+
((!!input.importedClaudeSession || !!input.worktreeAdoption) &&
71+
!!input.taskDescription?.trim());
6972
if (!hasDescription) {
7073
return {
7174
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/shared/src/task-creation-domain.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ export interface TaskCreationInput {
3131
// When a worktree is already checked out on the branch, opt in to reusing it
3232
// for this task instead of creating a new one (set after the user confirms).
3333
reuseExistingWorktree?: boolean;
34+
/**
35+
* Start-from-worktree flow: the task is created around an existing task-less
36+
* worktree with no typed prompt — the synthesized taskDescription (the branch
37+
* name) names the task and the agent session starts idle. Callers pair this
38+
* with reuseExistingWorktree and branch.
39+
*/
40+
worktreeAdoption?: boolean;
3441
githubIntegrationId?: number;
3542
githubUserIntegrationId?: string;
3643
executionMode?: ExecutionMode;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { GitBranch, Spinner, TreeStructure } from "@phosphor-icons/react";
2+
import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem";
3+
import { SidebarSection } from "@posthog/ui/features/sidebar/components/SidebarSection";
4+
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
5+
import { useAdoptableWorktrees } from "@posthog/ui/features/sidebar/useAdoptableWorktrees";
6+
import { useStartTaskFromWorktree } from "@posthog/ui/features/sidebar/useStartTaskFromWorktree";
7+
8+
interface GroupWorktreesSectionProps {
9+
groupId: string;
10+
mainRepoPath: string;
11+
}
12+
13+
/**
14+
* Nested "Worktrees" dropdown at the bottom of a repo group listing the repo's
15+
* task-less worktrees. Clicking one starts a task in that worktree and opens
16+
* its chat + shell. Renders nothing when the repo has no adoptable worktrees.
17+
*/
18+
export function GroupWorktreesSection({
19+
groupId,
20+
mainRepoPath,
21+
}: GroupWorktreesSectionProps) {
22+
const worktrees = useAdoptableWorktrees(mainRepoPath);
23+
const collapsedSections = useSidebarStore((state) => state.collapsedSections);
24+
const toggleSection = useSidebarStore((state) => state.toggleSection);
25+
const { startTask, startingBranches } =
26+
useStartTaskFromWorktree(mainRepoPath);
27+
28+
if (worktrees.length === 0) return null;
29+
30+
const sectionId = `worktrees:${groupId}`;
31+
return (
32+
<SidebarSection
33+
id={sectionId}
34+
label={`Worktrees (${worktrees.length})`}
35+
icon={<TreeStructure size={14} className="text-gray-10" />}
36+
depth={1}
37+
isExpanded={!collapsedSections.has(sectionId)}
38+
onToggle={() => toggleSection(sectionId)}
39+
tooltipContent="Worktrees without a task — click one to start a task there"
40+
>
41+
{worktrees.map((worktree) => {
42+
const isStarting = startingBranches.has(worktree.branch);
43+
return (
44+
<SidebarItem
45+
key={worktree.worktreePath}
46+
depth={2}
47+
icon={<GitBranch size={14} />}
48+
label={worktree.branch}
49+
isDimmed={isStarting}
50+
disabled={isStarting}
51+
endContent={
52+
isStarting ? (
53+
<Spinner size={12} className="animate-spin text-gray-10" />
54+
) : undefined
55+
}
56+
onClick={() => void startTask(worktree.branch)}
57+
/>
58+
);
59+
})}
60+
</SidebarSection>
61+
);
62+
}

packages/ui/src/features/sidebar/components/SidebarSection.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ interface SidebarSectionProps {
1717
onNewTask?: () => void;
1818
newTaskTooltip?: string;
1919
dragHandleRef?: React.RefCallback<HTMLButtonElement>;
20+
/** Indents the header for sections nested inside another section. */
21+
depth?: number;
2022
}
2123

2224
export function SidebarSection({
@@ -31,6 +33,7 @@ export function SidebarSection({
3133
onNewTask,
3234
newTaskTooltip,
3335
dragHandleRef,
36+
depth,
3437
}: SidebarSectionProps) {
3538
const [isHovered, setIsHovered] = useState(false);
3639

@@ -43,6 +46,7 @@ export function SidebarSection({
4346
className="flex w-full items-center justify-between pl-2 not-hover:aria-expanded:bg-transparent"
4447
style={{
4548
marginTop: addSpacingBefore ? "12px" : undefined,
49+
paddingLeft: depth ? `${depth * 8 + 8}px` : undefined,
4650
}}
4751
onContextMenu={onContextMenu}
4852
onMouseEnter={() => setIsHovered(true)}

0 commit comments

Comments
 (0)