Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
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
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