Skip to content
46 changes: 46 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const mockHost = vi.hoisted(() => ({
}));

import { TaskCreationSaga } from "./taskCreationSaga";
import { buildWorktreeAdoptionInput } from "./taskInput";

const host = mockHost as unknown as ITaskCreationHost;

Expand Down Expand Up @@ -1026,6 +1027,51 @@ describe("TaskCreationSaga", () => {
);
});

it("adopts an existing worktree into a promptless task (worktree adoption)", async () => {
const createTaskMock = vi.fn().mockResolvedValue(createTask());
mockHost.addFolder.mockResolvedValue({ id: "folder-1", path: "/repo" });
mockHost.detectRepo.mockResolvedValue(null);
mockHost.createWorkspace.mockResolvedValue({
taskId: "task-123",
mode: "worktree",
worktree: {
worktreePath: "/wt/orphan",
worktreeName: "orphan",
branchName: "feature/orphan",
baseBranch: "",
createdAt: "",
},
branchName: "feature/orphan",
linkedBranch: null,
});

const saga = makeSaga({ createTask: createTaskMock });

const result = await saga.run(
buildWorktreeAdoptionInput({
repoPath: "/repo",
branch: "feature/orphan",
}),
);

expect(result.success).toBe(true);
// The branch doubles as the task description so the task is named after it.
expect(createTaskMock).toHaveBeenCalledWith(
expect.objectContaining({ description: "feature/orphan" }),
);
expect(mockHost.createWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
branch: "feature/orphan",
reuseExistingWorktree: true,
}),
);
// No typed prompt: the agent session starts idle in the adopted worktree.
const connectParams = vi.mocked(sessionService.connectToTask).mock
.calls[0][0];
expect(connectParams.repoPath).toBe("/wt/orphan");
expect(connectParams.initialPrompt).toBeUndefined();
});

it("creates the task without a repository when repo detection fails", async () => {
const createTaskMock = vi.fn().mockResolvedValue(createTask());
mockHost.addFolder.mockResolvedValue({ id: "folder-1", path: "/repo" });
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/task-detail/taskInput.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { prepareTaskInput } from "./taskInput";
import { buildWorktreeAdoptionInput, prepareTaskInput } from "./taskInput";

describe("prepareTaskInput", () => {
// The isCloud guard on customInstructions is the only thing preventing
Expand Down Expand Up @@ -28,3 +28,23 @@ describe("prepareTaskInput", () => {
expect(input.customInstructions).toBeUndefined();
});
});

describe("buildWorktreeAdoptionInput", () => {
it("builds a promptless worktree input that adopts the branch's worktree", () => {
const input = buildWorktreeAdoptionInput({
repoPath: "/repo",
branch: "feature/orphan",
});

expect(input).toEqual({
taskDescription: "feature/orphan",
repoPath: "/repo",
workspaceMode: "worktree",
branch: "feature/orphan",
reuseExistingWorktree: true,
});
// No content: the saga must not build an initial prompt, so the agent
// session starts idle in the adopted worktree.
expect(input.content).toBeUndefined();
});
});
19 changes: 19 additions & 0 deletions packages/core/src/task-detail/taskInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,25 @@ export function prepareTaskInput(
};
}

/**
* Input for starting a task from an existing task-less worktree (the sidebar's
* one-click adoption). No content: the agent session starts idle and the user
* types the first message in the opened chat. The branch doubles as the task
* description so the task is named after it.
*/
export function buildWorktreeAdoptionInput(options: {
repoPath: string;
branch: string;
}): TaskCreationInput {
return {
taskDescription: options.branch,
repoPath: options.repoPath,
workspaceMode: "worktree",
branch: options.branch,
reuseExistingWorktree: true,
};
}

const ERROR_TITLES: Record<string, string> = {
repo_detection: "Failed to detect repository",
task_creation: "Failed to create task",
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/task-detail/taskService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { SessionService } from "@posthog/core/sessions/sessionService";
import type { RootLogger } from "@posthog/di/logger";
import { describe, expect, it, vi } from "vitest";
import type { TaskCreationEffects } from "./taskCreationEffects";
import type { ITaskCreationHost } from "./taskCreationHost";
import { buildWorktreeAdoptionInput } from "./taskInput";
import { TaskService } from "./taskService";

const scopedLog = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const rootLogger = {
...scopedLog,
scope: () => scopedLog,
} as unknown as RootLogger;

function makeService(): TaskService {
const host = {
// The API client's createTask rejects so tests can observe that an input
// made it past validation (failedStep lands on task_creation, not
// validation) without faking the whole saga.
getAuthenticatedClient: vi.fn(async () => ({
createTask: vi.fn().mockRejectedValue(new Error("api down")),
deleteTask: vi.fn(),
getTask: vi.fn(),
createTaskRun: vi.fn(),
startTaskRun: vi.fn(),
sendRunCommand: vi.fn(),
updateTask: vi.fn(),
})),
detectRepo: vi.fn(async () => null),
getFolders: vi.fn(async () => []),
addFolder: vi.fn(async () => ({ id: "folder-1", path: "/repo" })),
track: vi.fn(),
} as unknown as ITaskCreationHost;
const sessionService = {
markTaskCreationInFlight: vi.fn(),
connectToTask: vi.fn(),
disconnectFromTask: vi.fn(),
} as unknown as SessionService;
const effects = {
onWorkspaceCreated: vi.fn(),
onCreateSuccess: vi.fn(),
} as unknown as TaskCreationEffects;
return new TaskService(host, sessionService, effects, rootLogger);
}

describe("TaskService.createTask validation", () => {
it("rejects an input with neither content nor a taskDescription", async () => {
const result = await makeService().createTask({
content: " ",
repoPath: "/repo",
workspaceMode: "worktree",
});

expect(result.success).toBe(false);
if (result.success) throw new Error("expected validation failure");
expect(result.failedStep).toBe("validation");
});

it("accepts a promptless worktree-adoption input", async () => {
const result = await makeService().createTask(
buildWorktreeAdoptionInput({
repoPath: "/repo",
branch: "feature/orphan",
}),
);

// The stubbed API call fails, proving the input got past validation.
expect(result.success).toBe(false);
if (result.success) throw new Error("expected task_creation failure");
expect(result.failedStep).toBe("task_creation");
});
});
8 changes: 4 additions & 4 deletions packages/core/src/task-detail/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ export class TaskService {
hasRepo: !!input.repository,
});

// Imported Claude Code sessions carry a transcript, not a typed prompt, so
// they supply a taskDescription instead of content.
// Promptless flows (imported Claude Code sessions, worktree adoption)
// synthesize a taskDescription instead of typed content; either one names
// the task, so either satisfies validation.
const hasDescription =
!!input.content?.trim() ||
(!!input.importedClaudeSession && !!input.taskDescription?.trim());
!!input.content?.trim() || !!input.taskDescription?.trim();
if (!hasDescription) {
return {
success: false,
Expand Down
9 changes: 9 additions & 0 deletions packages/host-router/src/routers/workspace.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
getWorktreeTasksInput,
getWorktreeTasksOutput,
linkBranchInput,
listAdoptableWorktreesInput,
listAdoptableWorktreesOutput,
listGitWorktreesInput,
listGitWorktreesOutput,
listRepoCheckoutsInput,
Expand Down Expand Up @@ -164,6 +166,13 @@ export const workspaceRouter = router({
getService(ctx.container).listRepoCheckouts(input.repoPath),
),

listAdoptableWorktrees: publicProcedure
.input(listAdoptableWorktreesInput)
.output(listAdoptableWorktreesOutput)
.query(({ ctx, input }) =>
getService(ctx.container).listAdoptableWorktrees(input.mainRepoPath),
),

getWorktreeSize: publicProcedure
.input(getWorktreeSizeInput)
.output(getWorktreeSizeOutput)
Expand Down
6 changes: 5 additions & 1 deletion packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export interface PromptHistorySelectedProperties {

type ExecutionType = "cloud" | "local";
export type RepositoryProvider = "github" | "gitlab" | "local" | "none";
type TaskCreatedFrom = "cli" | "command-menu" | "home-quick-action";
type TaskCreatedFrom =
| "cli"
| "command-menu"
| "home-quick-action"
| "sidebar-worktree";
type RepositorySelectSource = "task-creation" | "task-detail";
type GitActionType =
| "push"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useDeleteTask } from "../../../tasks/useTaskCrudMutations";
import { useTasks } from "../../../tasks/useTasks";
import { WORKSPACE_QUERY_KEY } from "../../../workspace/identifiers";
import { SettingRow } from "../../SettingRow";
import { useSettingsStore } from "../../settingsStore";
import { WorktreeGroupSection } from "./WorktreeGroupSection";

const log = logger.scope("worktrees-settings");
Expand All @@ -25,6 +26,12 @@ export function WorktreesSettings() {
const trpc = useHostTRPC();
const hostClient = useHostTRPCClient();
const { settings, updateSettings } = useSuspensionSettings();
const showSidebarWorktrees = useSettingsStore(
(state) => state.showSidebarWorktrees,
);
const setShowSidebarWorktrees = useSettingsStore(
(state) => state.setShowSidebarWorktrees,
);
const { mutateAsync: deleteTask } = useDeleteTask();
const [deletingWorktrees, setDeletingWorktrees] = useState<Set<string>>(
new Set(),
Expand Down Expand Up @@ -133,6 +140,16 @@ export function WorktreesSettings() {
return (
<Flex direction="column" gap="5">
<Flex direction="column">
<SettingRow
label="Show worktrees in sidebar"
description="List worktrees that have no task under each repo in the sidebar, so you can start a task in one with a click"
>
<Switch
checked={showSidebarWorktrees}
onCheckedChange={setShowSidebarWorktrees}
size="1"
/>
</SettingRow>
<SettingRow
label="Automatically suspend stale worktrees"
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."
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/features/settings/settingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ describe("feature settingsStore cloud selections", () => {
["debugLogsCloudRuns", false, true],
["slotMachineMode", false, true],
["dismissibleUpdateBanners", false, true],
["showSidebarWorktrees", false, true],
] as const)("rehydrates %s", async (field, initial, persisted) => {
getItem.mockResolvedValue(
JSON.stringify({ state: { [field]: persisted }, version: 0 }),
Expand Down
14 changes: 14 additions & 0 deletions packages/ui/src/features/settings/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ interface SettingsStore {
conversationCollapseMode: CollapseMode;
setConversationCollapseMode: (mode: CollapseMode) => void;

// Sidebar
// Shows a per-repo "Worktrees" dropdown of task-less worktrees a click can
// start a task in. Opt-in: off by default to keep the sidebar uncluttered.
showSidebarWorktrees: boolean;
setShowSidebarWorktrees: (enabled: boolean) => void;

// Experimental / misc
hedgehogMode: boolean;
slotMachineMode: boolean;
Expand Down Expand Up @@ -447,6 +453,11 @@ export const useSettingsStore = create<SettingsStore>()(
setConversationCollapseMode: (mode) =>
set({ conversationCollapseMode: mode }),

// Sidebar
showSidebarWorktrees: false,
setShowSidebarWorktrees: (enabled) =>
set({ showSidebarWorktrees: enabled }),

// Experimental / misc
hedgehogMode: false,
slotMachineMode: false,
Expand Down Expand Up @@ -564,6 +575,9 @@ export const useSettingsStore = create<SettingsStore>()(
// Conversation thread (new-thread)
conversationCollapseMode: state.conversationCollapseMode,

// Sidebar
showSidebarWorktrees: state.showSidebarWorktrees,

// Experimental / misc
hedgehogMode: state.hedgehogMode,
slotMachineMode: state.slotMachineMode,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { GitBranch, Spinner, TreeStructure } from "@phosphor-icons/react";
import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem";
import { SidebarSection } from "@posthog/ui/features/sidebar/components/SidebarSection";
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
import { useAdoptableWorktrees } from "@posthog/ui/features/sidebar/useAdoptableWorktrees";
import { useStartTaskFromWorktree } from "@posthog/ui/features/sidebar/useStartTaskFromWorktree";

interface GroupWorktreesSectionProps {
groupId: string;
mainRepoPath: string;
}

/**
* Nested "Worktrees" dropdown at the bottom of a repo group listing the repo's
* task-less worktrees. Clicking one starts a task in that worktree and opens
* its chat + shell. Renders nothing when the repo has no adoptable worktrees.
*/
export function GroupWorktreesSection({
groupId,
mainRepoPath,
}: GroupWorktreesSectionProps) {
const worktrees = useAdoptableWorktrees(mainRepoPath);
const collapsedSections = useSidebarStore((state) => state.collapsedSections);
const toggleSection = useSidebarStore((state) => state.toggleSection);
const { startTask, startingBranches } =
useStartTaskFromWorktree(mainRepoPath);

if (worktrees.length === 0) return null;

const sectionId = `worktrees:${groupId}`;
return (
<SidebarSection
id={sectionId}
label={`Worktrees (${worktrees.length})`}
icon={<TreeStructure size={14} className="text-gray-10" />}
depth={1}
isExpanded={!collapsedSections.has(sectionId)}
onToggle={() => toggleSection(sectionId)}
tooltipContent="Worktrees without a task — click one to start a task there"
>
{worktrees.map((worktree) => {
const isStarting = startingBranches.has(worktree.branch);
return (
<SidebarItem
key={worktree.worktreePath}
depth={2}
icon={<GitBranch size={14} />}
label={worktree.branch}
isDimmed={isStarting}
disabled={isStarting}
endContent={
isStarting ? (
<Spinner size={12} className="animate-spin text-gray-10" />
) : undefined
}
onClick={() => void startTask(worktree.branch)}
/>
);
})}
</SidebarSection>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import type { SidebarItemAction } from "@posthog/ui/features/sidebar/types";
import { useCallback } from "react";

const INDENT_SIZE = 8;
export const INDENT_SIZE = 8;

interface SidebarItemProps {
depth: number;
Expand Down
Loading
Loading