From b7365eae252deaafcd5c24b27502e6aa072ebd15 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 16 Jul 2026 12:29:30 -0700 Subject: [PATCH 1/5] 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 --- .../src/task-detail/taskCreationSaga.test.ts | 46 ++++++++++ .../core/src/task-detail/taskInput.test.ts | 23 ++++- packages/core/src/task-detail/taskInput.ts | 20 +++++ .../core/src/task-detail/taskService.test.ts | 77 +++++++++++++++++ packages/core/src/task-detail/taskService.ts | 7 +- .../src/routers/workspace.router.ts | 9 ++ packages/shared/src/analytics-events.ts | 6 +- packages/shared/src/task-creation-domain.ts | 7 ++ .../components/GroupWorktreesSection.tsx | 62 ++++++++++++++ .../sidebar/components/SidebarSection.tsx | 4 + .../sidebar/components/TaskListView.tsx | 7 ++ .../features/sidebar/useAdoptableWorktrees.ts | 16 ++++ .../sidebar/useStartTaskFromWorktree.ts | 84 +++++++++++++++++++ .../src/services/workspace/schemas.ts | 13 +++ .../src/services/workspace/workspace.test.ts | 53 +++++++++++- .../src/services/workspace/workspace.ts | 30 +++++++ 16 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/task-detail/taskService.test.ts create mode 100644 packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx create mode 100644 packages/ui/src/features/sidebar/useAdoptableWorktrees.ts create mode 100644 packages/ui/src/features/sidebar/useStartTaskFromWorktree.ts diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index b53f5f990f..6a06da8206 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -36,6 +36,7 @@ const mockHost = vi.hoisted(() => ({ })); import { TaskCreationSaga } from "./taskCreationSaga"; +import { buildWorktreeAdoptionInput } from "./taskInput"; const host = mockHost as unknown as ITaskCreationHost; @@ -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" }); diff --git a/packages/core/src/task-detail/taskInput.test.ts b/packages/core/src/task-detail/taskInput.test.ts index f45243d608..80d40cbecd 100644 --- a/packages/core/src/task-detail/taskInput.test.ts +++ b/packages/core/src/task-detail/taskInput.test.ts @@ -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 @@ -28,3 +28,24 @@ 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, + worktreeAdoption: 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(); + }); +}); diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 73bc9550d8..25581940c3 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -82,6 +82,26 @@ 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, + worktreeAdoption: true, + }; +} + const ERROR_TITLES: Record = { repo_detection: "Failed to detect repository", task_creation: "Failed to create task", diff --git a/packages/core/src/task-detail/taskService.test.ts b/packages/core/src/task-detail/taskService.test.ts new file mode 100644 index 0000000000..d4aa5f25ac --- /dev/null +++ b/packages/core/src/task-detail/taskService.test.ts @@ -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 a promptless input without the adoption or import markers", async () => { + const result = await makeService().createTask({ + taskDescription: "feature/orphan", + 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"); + }); +}); diff --git a/packages/core/src/task-detail/taskService.ts b/packages/core/src/task-detail/taskService.ts index 0989046c37..9493f4cc12 100644 --- a/packages/core/src/task-detail/taskService.ts +++ b/packages/core/src/task-detail/taskService.ts @@ -62,10 +62,13 @@ export class TaskService { }); // Imported Claude Code sessions carry a transcript, not a typed prompt, so - // they supply a taskDescription instead of content. + // they supply a taskDescription instead of content. Worktree-adoption tasks + // have no prompt either — the user types their first message in the opened + // chat — so they too rely on a synthesized taskDescription. const hasDescription = !!input.content?.trim() || - (!!input.importedClaudeSession && !!input.taskDescription?.trim()); + ((!!input.importedClaudeSession || !!input.worktreeAdoption) && + !!input.taskDescription?.trim()); if (!hasDescription) { return { success: false, diff --git a/packages/host-router/src/routers/workspace.router.ts b/packages/host-router/src/routers/workspace.router.ts index 6a1e65179d..94e8094ea5 100644 --- a/packages/host-router/src/routers/workspace.router.ts +++ b/packages/host-router/src/routers/workspace.router.ts @@ -28,6 +28,8 @@ import { getWorktreeTasksInput, getWorktreeTasksOutput, linkBranchInput, + listAdoptableWorktreesInput, + listAdoptableWorktreesOutput, listGitWorktreesInput, listGitWorktreesOutput, listRepoCheckoutsInput, @@ -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) diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 32756ce73f..83358a409a 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -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" diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 8b073027a9..0a702fe8e5 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -31,6 +31,13 @@ export interface TaskCreationInput { // When a worktree is already checked out on the branch, opt in to reusing it // for this task instead of creating a new one (set after the user confirms). reuseExistingWorktree?: boolean; + /** + * Start-from-worktree flow: the task is created around an existing task-less + * worktree with no typed prompt — the synthesized taskDescription (the branch + * name) names the task and the agent session starts idle. Callers pair this + * with reuseExistingWorktree and branch. + */ + worktreeAdoption?: boolean; githubIntegrationId?: number; githubUserIntegrationId?: string; executionMode?: ExecutionMode; diff --git a/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx b/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx new file mode 100644 index 0000000000..3ef47021e9 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx @@ -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 ( + } + 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 ( + } + label={worktree.branch} + isDimmed={isStarting} + disabled={isStarting} + endContent={ + isStarting ? ( + + ) : undefined + } + onClick={() => void startTask(worktree.branch)} + /> + ); + })} + + ); +} diff --git a/packages/ui/src/features/sidebar/components/SidebarSection.tsx b/packages/ui/src/features/sidebar/components/SidebarSection.tsx index 2c69285b71..5289ffa7a0 100644 --- a/packages/ui/src/features/sidebar/components/SidebarSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarSection.tsx @@ -17,6 +17,8 @@ interface SidebarSectionProps { onNewTask?: () => void; newTaskTooltip?: string; dragHandleRef?: React.RefCallback; + /** Indents the header for sections nested inside another section. */ + depth?: number; } export function SidebarSection({ @@ -31,6 +33,7 @@ export function SidebarSection({ onNewTask, newTaskTooltip, dragHandleRef, + depth, }: SidebarSectionProps) { const [isHovered, setIsHovered] = useState(false); @@ -43,6 +46,7 @@ export function SidebarSection({ className="flex w-full items-center justify-between pl-2 not-hover:aria-expanded:bg-transparent" style={{ marginTop: addSpacingBefore ? "12px" : undefined, + paddingLeft: depth ? `${depth * 8 + 8}px` : undefined, }} onContextMenu={onContextMenu} onMouseEnter={() => setIsHovered(true)} diff --git a/packages/ui/src/features/sidebar/components/TaskListView.tsx b/packages/ui/src/features/sidebar/components/TaskListView.tsx index 102da76788..18381adf6e 100644 --- a/packages/ui/src/features/sidebar/components/TaskListView.tsx +++ b/packages/ui/src/features/sidebar/components/TaskListView.tsx @@ -17,6 +17,7 @@ import { builderHog } from "@posthog/ui/assets/hedgehogs"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; import { useArchivingTasksStore } from "@posthog/ui/features/sidebar/archivingTasksStore"; import { DraggableFolder } from "@posthog/ui/features/sidebar/components/DraggableFolder"; +import { GroupWorktreesSection } from "@posthog/ui/features/sidebar/components/GroupWorktreesSection"; import { TaskItem } from "@posthog/ui/features/sidebar/components/items/TaskItem"; import { SidebarSection } from "@posthog/ui/features/sidebar/components/SidebarSection"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; @@ -369,6 +370,12 @@ export function TaskListView({ /> )) )} + {folder && ( + + )} ); diff --git a/packages/ui/src/features/sidebar/useAdoptableWorktrees.ts b/packages/ui/src/features/sidebar/useAdoptableWorktrees.ts new file mode 100644 index 0000000000..656471e06e --- /dev/null +++ b/packages/ui/src/features/sidebar/useAdoptableWorktrees.ts @@ -0,0 +1,16 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +const NO_WORKTREES: never[] = []; + +/** Task-less linked worktrees of a repo the sidebar offers to start a task in. */ +export function useAdoptableWorktrees(mainRepoPath: string) { + const trpc = useHostTRPC(); + const { data } = useQuery( + trpc.workspace.listAdoptableWorktrees.queryOptions( + { mainRepoPath }, + { staleTime: 30_000 }, + ), + ); + return data ?? NO_WORKTREES; +} diff --git a/packages/ui/src/features/sidebar/useStartTaskFromWorktree.ts b/packages/ui/src/features/sidebar/useStartTaskFromWorktree.ts new file mode 100644 index 0000000000..6e1a345fb4 --- /dev/null +++ b/packages/ui/src/features/sidebar/useStartTaskFromWorktree.ts @@ -0,0 +1,84 @@ +import { + buildWorktreeAdoptionInput, + getErrorTitle, +} from "@posthog/core/task-detail/taskInput"; +import { + TASK_SERVICE, + type TaskService, +} from "@posthog/core/task-detail/taskService"; +import { useService } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { toastError } from "@posthog/ui/features/notifications/errorDetails"; +import { useProvisioningStore } from "@posthog/ui/features/provisioning/store"; +import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; +import { openTask } from "@posthog/ui/router/useOpenTask"; +import { track } from "@posthog/ui/shell/analytics"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; + +/** + * Starts a task in an existing task-less worktree: creates a promptless task + * named after the branch, adopts the worktree for its workspace, and opens the + * task's chat + shell. + */ +export function useStartTaskFromWorktree(mainRepoPath: string) { + const taskService = useService(TASK_SERVICE); + const { invalidateTasks } = useCreateTask(); + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + const [startingBranches, setStartingBranches] = useState>( + new Set(), + ); + + const startTask = useCallback( + async (branch: string) => { + setStartingBranches((prev) => new Set(prev).add(branch)); + try { + const result = await taskService.createTask( + buildWorktreeAdoptionInput({ repoPath: mainRepoPath, branch }), + (output) => { + invalidateTasks(output.task); + void openTask(output.task); + }, + ); + + if (!result.success) { + toastError(getErrorTitle(result.failedStep), result.error); + return; + } + if (result.data.provisioningError) { + // The task was kept for retry; the task view shows a retry prompt. + useProvisioningStore + .getState() + .setFailed(result.data.task.id, result.data.provisioningError); + toastError( + getErrorTitle("workspace_creation"), + result.data.provisioningError, + ); + } + track(ANALYTICS_EVENTS.TASK_CREATED, { + auto_run: false, + created_from: "sidebar-worktree", + workspace_mode: "worktree", + has_branch: true, + }); + // The adopted worktree now has a task, so it leaves the adoptable list. + void queryClient.invalidateQueries( + trpc.workspace.listAdoptableWorktrees.queryFilter({ mainRepoPath }), + ); + } catch (error) { + toastError("Failed to start task from worktree", error); + } finally { + setStartingBranches((prev) => { + const next = new Set(prev); + next.delete(branch); + return next; + }); + } + }, + [taskService, invalidateTasks, mainRepoPath, queryClient, trpc], + ); + + return { startTask, startingBranches }; +} diff --git a/packages/workspace-server/src/services/workspace/schemas.ts b/packages/workspace-server/src/services/workspace/schemas.ts index 4d08d5587d..0de533573e 100644 --- a/packages/workspace-server/src/services/workspace/schemas.ts +++ b/packages/workspace-server/src/services/workspace/schemas.ts @@ -226,6 +226,18 @@ export const repoCheckoutSchema = z.object({ export const listRepoCheckoutsOutput = z.array(repoCheckoutSchema); +export const listAdoptableWorktreesInput = z.object({ + mainRepoPath: z.string(), +}); + +// A task-less linked worktree the sidebar offers to start a task in. +export const adoptableWorktreeSchema = z.object({ + worktreePath: z.string(), + branch: z.string(), +}); + +export const listAdoptableWorktreesOutput = z.array(adoptableWorktreeSchema); + export const getWorktreeSizeInput = z.object({ worktreePath: z.string(), }); @@ -337,6 +349,7 @@ export type GetWorkspaceInfoInput = z.infer; export type ListGitWorktreesInput = z.infer; export type ListRepoCheckoutsInput = z.infer; export type RepoCheckout = z.infer; +export type AdoptableWorktree = z.infer; export type GetWorktreeSizeInput = z.infer; export type DeleteWorktreeInput = z.infer; export type WorkspaceErrorPayload = z.infer; diff --git a/packages/workspace-server/src/services/workspace/workspace.test.ts b/packages/workspace-server/src/services/workspace/workspace.test.ts index 36c703e591..11ff64591e 100644 --- a/packages/workspace-server/src/services/workspace/workspace.test.ts +++ b/packages/workspace-server/src/services/workspace/workspace.test.ts @@ -20,7 +20,10 @@ import { createMockWorktreeRepository } from "../../db/repositories/worktree-rep import type { DatabaseService } from "../../db/service"; import type { ProcessTrackingService } from "../process-tracking/process-tracking"; import type { SuspensionService } from "../suspension/suspension"; -import { listLinkedWorktrees } from "../worktree-query/worktree-query"; +import { + listLinkedWorktrees, + resolveLocalWorktreePath, +} from "../worktree-query/worktree-query"; import type { WorkspaceAgent, WorkspaceFileWatcher, @@ -53,6 +56,7 @@ vi.mock("../worktree-query/worktree-query", async (importOriginal) => { deleteWorktree: vi.fn(async () => {}), listTwigWorktrees: vi.fn(), listLinkedWorktrees: vi.fn(), + resolveLocalWorktreePath: vi.fn(async (): Promise => null), }; }); @@ -555,6 +559,53 @@ describe("WorkspaceService", () => { }); }); + describe("listAdoptableWorktrees", () => { + const mainRepoPath = "/tmp/repo"; + + beforeEach(() => { + vi.mocked(listLinkedWorktrees).mockResolvedValue([]); + vi.mocked(resolveLocalWorktreePath).mockResolvedValue(null); + }); + + it("returns only task-less branch worktrees that are not registered folders", async () => { + vi.mocked(listLinkedWorktrees).mockResolvedValue([ + { worktreePath: "/wt/orphan", head: "a1", branch: "feature/orphan" }, + { worktreePath: "/wt/detached", head: "b2", branch: null }, + { + worktreePath: "/wt/registered", + head: "c3", + branch: "feature/registered", + }, + { worktreePath: "/wt/tasked", head: "d4", branch: "feature/tasked" }, + ]); + // A worktree the user registered as its own sidebar folder. + mocks.repositoryRepo.create({ path: "/wt/registered" }); + // A worktree already owned by a task. + seedWorktreeTask(mocks, { + taskId: "task-1", + repoPath: mainRepoPath, + name: "tasked", + worktreePath: "/wt/tasked", + }); + + expect(await service.listAdoptableWorktrees(mainRepoPath)).toEqual([ + { worktreePath: "/wt/orphan", branch: "feature/orphan" }, + ]); + }); + + it("excludes the hidden stash worktree that backgrounds the local checkout", async () => { + vi.mocked(listLinkedWorktrees).mockResolvedValue([ + { worktreePath: "/wt/local-stash", head: "a1", branch: "main" }, + { worktreePath: "/wt/orphan", head: "b2", branch: "feature/orphan" }, + ]); + vi.mocked(resolveLocalWorktreePath).mockResolvedValue("/wt/local-stash"); + + expect(await service.listAdoptableWorktrees(mainRepoPath)).toEqual([ + { worktreePath: "/wt/orphan", branch: "feature/orphan" }, + ]); + }); + }); + describe("createWorkspace (worktree reuse)", () => { const mainRepoPath = "/tmp/repo"; diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index 48b2a0d70d..ef31e8a588 100644 --- a/packages/workspace-server/src/services/workspace/workspace.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -1472,6 +1472,36 @@ export class WorkspaceService extends TypedEventEmitter return others.map((wt) => ({ path: wt.worktreePath, branch: wt.branch })); } + /** + * Linked worktrees (any location) that no task uses and a new task could + * adopt: checked out on a real branch, not themselves a registered folder, + * and not the hidden stash worktree that backgrounds the local checkout. + * The sidebar offers these as one-click "start a task in this worktree" + * entries; adoption goes through createWorkspace with reuseExistingWorktree. + */ + async listAdoptableWorktrees( + mainRepoPath: string, + ): Promise> { + const [linkedWorktrees, localStashPath] = await Promise.all([ + listLinkedWorktrees(mainRepoPath), + this.getLocalWorktreePathIfExists(mainRepoPath), + ]); + + return linkedWorktrees + .filter( + (wt) => + wt.branch !== null && + wt.worktreePath !== localStashPath && + !this.repositoryRepo.findByPath(wt.worktreePath) && + this.getWorktreeTasks(wt.worktreePath).length === 0, + ) + .map((wt) => ({ + worktreePath: wt.worktreePath, + // Narrowed by the branch !== null filter above. + branch: wt.branch as string, + })); + } + async deleteWorktree( mainRepoPath: string, worktreePath: string, From d65413d4fa4c6d2d3daa1bfb830a857efd071bca Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 16 Jul 2026 14:49:28 -0700 Subject: [PATCH 2/5] Add setting to hide sidebar worktrees and collapse them by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Worktrees gains a "Show worktrees in sidebar" toggle (on by default) that hides the per-repo worktrees dropdown and skips its queries entirely. The dropdown also starts collapsed: expansion is tracked as the exception in a new expandedWorktreeSections set, since collapsedSections defaults sections to expanded. Generated-By: PostHog Code Task-Id: 24032eac-82cc-4c5f-a7e9-30ed9278a6e1 --- .../sections/worktrees/WorktreesSettings.tsx | 17 ++++++++++++++++ .../ui/src/features/settings/settingsStore.ts | 14 +++++++++++++ .../components/GroupWorktreesSection.tsx | 15 +++++++++----- .../sidebar/components/TaskListView.tsx | 6 +++++- .../ui/src/features/sidebar/sidebarStore.ts | 20 +++++++++++++++++++ 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx b/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx index be36e6e81e..405d789d55 100644 --- a/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx +++ b/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx @@ -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"); @@ -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>( new Set(), @@ -133,6 +140,16 @@ export function WorktreesSettings() { return ( + + + void; + // Sidebar + // Shows a per-repo "Worktrees" dropdown of task-less worktrees a click can + // start a task in. + showSidebarWorktrees: boolean; + setShowSidebarWorktrees: (enabled: boolean) => void; + // Experimental / misc hedgehogMode: boolean; slotMachineMode: boolean; @@ -447,6 +453,11 @@ export const useSettingsStore = create()( setConversationCollapseMode: (mode) => set({ conversationCollapseMode: mode }), + // Sidebar + showSidebarWorktrees: true, + setShowSidebarWorktrees: (enabled) => + set({ showSidebarWorktrees: enabled }), + // Experimental / misc hedgehogMode: false, slotMachineMode: false, @@ -564,6 +575,9 @@ export const useSettingsStore = create()( // Conversation thread (new-thread) conversationCollapseMode: state.conversationCollapseMode, + // Sidebar + showSidebarWorktrees: state.showSidebarWorktrees, + // Experimental / misc hedgehogMode: state.hedgehogMode, slotMachineMode: state.slotMachineMode, diff --git a/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx b/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx index 3ef47021e9..42ad4874dd 100644 --- a/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx +++ b/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx @@ -13,15 +13,20 @@ interface GroupWorktreesSectionProps { /** * 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. + * its chat + shell. Collapsed by default; 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 expandedWorktreeSections = useSidebarStore( + (state) => state.expandedWorktreeSections, + ); + const toggleWorktreeSection = useSidebarStore( + (state) => state.toggleWorktreeSection, + ); const { startTask, startingBranches } = useStartTaskFromWorktree(mainRepoPath); @@ -34,8 +39,8 @@ export function GroupWorktreesSection({ label={`Worktrees (${worktrees.length})`} icon={} depth={1} - isExpanded={!collapsedSections.has(sectionId)} - onToggle={() => toggleSection(sectionId)} + isExpanded={expandedWorktreeSections.has(sectionId)} + onToggle={() => toggleWorktreeSection(sectionId)} tooltipContent="Worktrees without a task — click one to start a task there" > {worktrees.map((worktree) => { diff --git a/packages/ui/src/features/sidebar/components/TaskListView.tsx b/packages/ui/src/features/sidebar/components/TaskListView.tsx index 18381adf6e..6124cf8115 100644 --- a/packages/ui/src/features/sidebar/components/TaskListView.tsx +++ b/packages/ui/src/features/sidebar/components/TaskListView.tsx @@ -15,6 +15,7 @@ import { MenuLabel } from "@posthog/quill"; import { getFileName } from "@posthog/shared"; import { builderHog } from "@posthog/ui/assets/hedgehogs"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useArchivingTasksStore } from "@posthog/ui/features/sidebar/archivingTasksStore"; import { DraggableFolder } from "@posthog/ui/features/sidebar/components/DraggableFolder"; import { GroupWorktreesSection } from "@posthog/ui/features/sidebar/components/GroupWorktreesSection"; @@ -189,6 +190,9 @@ export function TaskListView({ (state) => state.resetHistoryVisibleCount, ); const { folders } = useFolders(); + const showSidebarWorktrees = useSettingsStore( + (state) => state.showSidebarWorktrees, + ); const view = useAppView(); const isOnTaskInput = view.type === "task-input" || view.type === "task-pending"; @@ -370,7 +374,7 @@ export function TaskListView({ /> )) )} - {folder && ( + {folder && showSidebarWorktrees && ( ; + // Per-repo "Worktrees" subsections start collapsed, so unlike + // collapsedSections this tracks the expanded exceptions. + expandedWorktreeSections: Set; folderOrder: string[]; historyVisibleCount: number; organizeMode: "by-project" | "chronological"; @@ -30,6 +33,7 @@ interface SidebarStoreActions { setWidth: (width: number) => void; setIsResizing: (isResizing: boolean) => void; toggleSection: (sectionId: string) => void; + toggleWorktreeSection: (sectionId: string) => void; reorderFolders: (fromIndex: number, toIndex: number) => void; setFolderOrder: (order: string[]) => void; syncFolderOrder: (folderIds: string[]) => void; @@ -53,6 +57,7 @@ export const useSidebarStore = create()( width: 256, isResizing: false, collapsedSections: new Set(), + expandedWorktreeSections: new Set(), folderOrder: [], historyVisibleCount: 25, organizeMode: "by-project", @@ -78,6 +83,16 @@ export const useSidebarStore = create()( } return { collapsedSections: newCollapsedSections }; }), + toggleWorktreeSection: (sectionId) => + set((state) => { + const next = new Set(state.expandedWorktreeSections); + if (next.has(sectionId)) { + next.delete(sectionId); + } else { + next.add(sectionId); + } + return { expandedWorktreeSections: next }; + }), reorderFolders: (fromIndex, toIndex) => set((state) => { const newOrder = [...state.folderOrder]; @@ -126,6 +141,7 @@ export const useSidebarStore = create()( hasUserSetOpen: state.hasUserSetOpen, width: state.width, collapsedSections: Array.from(state.collapsedSections), + expandedWorktreeSections: Array.from(state.expandedWorktreeSections), folderOrder: state.folderOrder, historyVisibleCount: state.historyVisibleCount, organizeMode: state.organizeMode, @@ -141,6 +157,7 @@ export const useSidebarStore = create()( hasUserSetOpen?: boolean; width?: number; collapsedSections?: string[]; + expandedWorktreeSections?: string[]; folderOrder?: string[]; historyVisibleCount?: number; organizeMode?: SidebarStoreState["organizeMode"]; @@ -160,6 +177,9 @@ export const useSidebarStore = create()( persistedState.width ?? current.width, ), collapsedSections: new Set(persistedState.collapsedSections ?? []), + expandedWorktreeSections: new Set( + persistedState.expandedWorktreeSections ?? [], + ), folderOrder: persistedState.folderOrder ?? [], historyVisibleCount: persistedState.historyVisibleCount ?? current.historyVisibleCount, From e18a42b64fef6a8d5336aa327984f6aa99b6d122 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 16 Jul 2026 15:00:41 -0700 Subject: [PATCH 3/5] Simplify worktree adoption after review - Validation in createTask now accepts content OR taskDescription instead of enumerating per-flow flags; the worktreeAdoption input flag existed only to gate that check, so it's gone - listAdoptableWorktrees builds occupied-path and registered-folder sets in one pass instead of re-querying workspace tables per worktree candidate - SidebarSection reuses the INDENT_SIZE constant from SidebarItem instead of a third copy of the indent unit Generated-By: PostHog Code Task-Id: 24032eac-82cc-4c5f-a7e9-30ed9278a6e1 --- packages/core/src/task-detail/taskInput.test.ts | 1 - packages/core/src/task-detail/taskInput.ts | 1 - packages/core/src/task-detail/taskService.test.ts | 4 ++-- packages/core/src/task-detail/taskService.ts | 11 ++++------- packages/shared/src/task-creation-domain.ts | 7 ------- .../features/sidebar/components/SidebarItem.tsx | 2 +- .../sidebar/components/SidebarSection.tsx | 3 ++- .../src/services/workspace/workspace.ts | 15 +++++++++++++-- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/core/src/task-detail/taskInput.test.ts b/packages/core/src/task-detail/taskInput.test.ts index 80d40cbecd..aa6d3d6dc1 100644 --- a/packages/core/src/task-detail/taskInput.test.ts +++ b/packages/core/src/task-detail/taskInput.test.ts @@ -42,7 +42,6 @@ describe("buildWorktreeAdoptionInput", () => { workspaceMode: "worktree", branch: "feature/orphan", reuseExistingWorktree: true, - worktreeAdoption: true, }); // No content: the saga must not build an initial prompt, so the agent // session starts idle in the adopted worktree. diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 25581940c3..63902749ca 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -98,7 +98,6 @@ export function buildWorktreeAdoptionInput(options: { workspaceMode: "worktree", branch: options.branch, reuseExistingWorktree: true, - worktreeAdoption: true, }; } diff --git a/packages/core/src/task-detail/taskService.test.ts b/packages/core/src/task-detail/taskService.test.ts index d4aa5f25ac..049e775170 100644 --- a/packages/core/src/task-detail/taskService.test.ts +++ b/packages/core/src/task-detail/taskService.test.ts @@ -49,9 +49,9 @@ function makeService(): TaskService { } describe("TaskService.createTask validation", () => { - it("rejects a promptless input without the adoption or import markers", async () => { + it("rejects an input with neither content nor a taskDescription", async () => { const result = await makeService().createTask({ - taskDescription: "feature/orphan", + content: " ", repoPath: "/repo", workspaceMode: "worktree", }); diff --git a/packages/core/src/task-detail/taskService.ts b/packages/core/src/task-detail/taskService.ts index 9493f4cc12..78e6e0c25f 100644 --- a/packages/core/src/task-detail/taskService.ts +++ b/packages/core/src/task-detail/taskService.ts @@ -61,14 +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. Worktree-adoption tasks - // have no prompt either — the user types their first message in the opened - // chat — so they too rely on a synthesized taskDescription. + // 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.worktreeAdoption) && - !!input.taskDescription?.trim()); + !!input.content?.trim() || !!input.taskDescription?.trim(); if (!hasDescription) { return { success: false, diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 0a702fe8e5..8b073027a9 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -31,13 +31,6 @@ export interface TaskCreationInput { // When a worktree is already checked out on the branch, opt in to reusing it // for this task instead of creating a new one (set after the user confirms). reuseExistingWorktree?: boolean; - /** - * Start-from-worktree flow: the task is created around an existing task-less - * worktree with no typed prompt — the synthesized taskDescription (the branch - * name) names the task and the agent session starts idle. Callers pair this - * with reuseExistingWorktree and branch. - */ - worktreeAdoption?: boolean; githubIntegrationId?: number; githubUserIntegrationId?: string; executionMode?: ExecutionMode; diff --git a/packages/ui/src/features/sidebar/components/SidebarItem.tsx b/packages/ui/src/features/sidebar/components/SidebarItem.tsx index b11117f09e..453623abe9 100644 --- a/packages/ui/src/features/sidebar/components/SidebarItem.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarItem.tsx @@ -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; diff --git a/packages/ui/src/features/sidebar/components/SidebarSection.tsx b/packages/ui/src/features/sidebar/components/SidebarSection.tsx index 5289ffa7a0..6d0c3c45f7 100644 --- a/packages/ui/src/features/sidebar/components/SidebarSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarSection.tsx @@ -1,5 +1,6 @@ import { CaretDownIcon, CaretRightIcon, Plus } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; +import { INDENT_SIZE } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; @@ -46,7 +47,7 @@ export function SidebarSection({ className="flex w-full items-center justify-between pl-2 not-hover:aria-expanded:bg-transparent" style={{ marginTop: addSpacingBefore ? "12px" : undefined, - paddingLeft: depth ? `${depth * 8 + 8}px` : undefined, + paddingLeft: depth ? `${depth * INDENT_SIZE + 8}px` : undefined, }} onContextMenu={onContextMenu} onMouseEnter={() => setIsHovered(true)} diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index ef31e8a588..626e1ea8c9 100644 --- a/packages/workspace-server/src/services/workspace/workspace.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -1487,13 +1487,24 @@ export class WorkspaceService extends TypedEventEmitter this.getLocalWorktreePathIfExists(mainRepoPath), ]); + // One pass over associations and registered folders up front; per-candidate + // lookups would re-query the workspace tables once per worktree. + const occupiedPaths = new Set( + this.getAllTaskAssociations().flatMap((assoc) => + assoc.mode === "worktree" ? [assoc.path] : [], + ), + ); + const registeredFolderPaths = new Set( + this.repositoryRepo.findAll().map((repo) => repo.path), + ); + return linkedWorktrees .filter( (wt) => wt.branch !== null && wt.worktreePath !== localStashPath && - !this.repositoryRepo.findByPath(wt.worktreePath) && - this.getWorktreeTasks(wt.worktreePath).length === 0, + !registeredFolderPaths.has(wt.worktreePath) && + !occupiedPaths.has(wt.worktreePath), ) .map((wt) => ({ worktreePath: wt.worktreePath, From 910357498a129463648b33d4bc1f8d1f6e7e10da Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 16 Jul 2026 15:10:03 -0700 Subject: [PATCH 4/5] Make sidebar worktrees opt-in and drop the inverse expansion set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the section hidden until the user enables it in Settings → Worktrees, expanded-by-default is the right behavior for those who opted in. That removes the need for expandedWorktreeSections — the inverse-polarity twin of collapsedSections — so the section now uses the shared collapsedSections/toggleSection mechanism like every other sidebar section. Generated-By: PostHog Code Task-Id: 24032eac-82cc-4c5f-a7e9-30ed9278a6e1 --- .../ui/src/features/settings/settingsStore.ts | 4 ++-- .../components/GroupWorktreesSection.tsx | 15 +++++--------- .../ui/src/features/sidebar/sidebarStore.ts | 20 ------------------- 3 files changed, 7 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 2adb2bc055..161ed7a11a 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -232,7 +232,7 @@ interface SettingsStore { // Sidebar // Shows a per-repo "Worktrees" dropdown of task-less worktrees a click can - // start a task in. + // start a task in. Opt-in: off by default to keep the sidebar uncluttered. showSidebarWorktrees: boolean; setShowSidebarWorktrees: (enabled: boolean) => void; @@ -454,7 +454,7 @@ export const useSettingsStore = create()( set({ conversationCollapseMode: mode }), // Sidebar - showSidebarWorktrees: true, + showSidebarWorktrees: false, setShowSidebarWorktrees: (enabled) => set({ showSidebarWorktrees: enabled }), diff --git a/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx b/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx index 42ad4874dd..3ef47021e9 100644 --- a/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx +++ b/packages/ui/src/features/sidebar/components/GroupWorktreesSection.tsx @@ -13,20 +13,15 @@ interface GroupWorktreesSectionProps { /** * 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. Collapsed by default; renders nothing when the repo has no - * adoptable worktrees. + * its chat + shell. Renders nothing when the repo has no adoptable worktrees. */ export function GroupWorktreesSection({ groupId, mainRepoPath, }: GroupWorktreesSectionProps) { const worktrees = useAdoptableWorktrees(mainRepoPath); - const expandedWorktreeSections = useSidebarStore( - (state) => state.expandedWorktreeSections, - ); - const toggleWorktreeSection = useSidebarStore( - (state) => state.toggleWorktreeSection, - ); + const collapsedSections = useSidebarStore((state) => state.collapsedSections); + const toggleSection = useSidebarStore((state) => state.toggleSection); const { startTask, startingBranches } = useStartTaskFromWorktree(mainRepoPath); @@ -39,8 +34,8 @@ export function GroupWorktreesSection({ label={`Worktrees (${worktrees.length})`} icon={} depth={1} - isExpanded={expandedWorktreeSections.has(sectionId)} - onToggle={() => toggleWorktreeSection(sectionId)} + isExpanded={!collapsedSections.has(sectionId)} + onToggle={() => toggleSection(sectionId)} tooltipContent="Worktrees without a task — click one to start a task there" > {worktrees.map((worktree) => { diff --git a/packages/ui/src/features/sidebar/sidebarStore.ts b/packages/ui/src/features/sidebar/sidebarStore.ts index 09eb02e1bb..ea36dd2b20 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.ts @@ -10,9 +10,6 @@ interface SidebarStoreState { width: number; isResizing: boolean; collapsedSections: Set; - // Per-repo "Worktrees" subsections start collapsed, so unlike - // collapsedSections this tracks the expanded exceptions. - expandedWorktreeSections: Set; folderOrder: string[]; historyVisibleCount: number; organizeMode: "by-project" | "chronological"; @@ -33,7 +30,6 @@ interface SidebarStoreActions { setWidth: (width: number) => void; setIsResizing: (isResizing: boolean) => void; toggleSection: (sectionId: string) => void; - toggleWorktreeSection: (sectionId: string) => void; reorderFolders: (fromIndex: number, toIndex: number) => void; setFolderOrder: (order: string[]) => void; syncFolderOrder: (folderIds: string[]) => void; @@ -57,7 +53,6 @@ export const useSidebarStore = create()( width: 256, isResizing: false, collapsedSections: new Set(), - expandedWorktreeSections: new Set(), folderOrder: [], historyVisibleCount: 25, organizeMode: "by-project", @@ -83,16 +78,6 @@ export const useSidebarStore = create()( } return { collapsedSections: newCollapsedSections }; }), - toggleWorktreeSection: (sectionId) => - set((state) => { - const next = new Set(state.expandedWorktreeSections); - if (next.has(sectionId)) { - next.delete(sectionId); - } else { - next.add(sectionId); - } - return { expandedWorktreeSections: next }; - }), reorderFolders: (fromIndex, toIndex) => set((state) => { const newOrder = [...state.folderOrder]; @@ -141,7 +126,6 @@ export const useSidebarStore = create()( hasUserSetOpen: state.hasUserSetOpen, width: state.width, collapsedSections: Array.from(state.collapsedSections), - expandedWorktreeSections: Array.from(state.expandedWorktreeSections), folderOrder: state.folderOrder, historyVisibleCount: state.historyVisibleCount, organizeMode: state.organizeMode, @@ -157,7 +141,6 @@ export const useSidebarStore = create()( hasUserSetOpen?: boolean; width?: number; collapsedSections?: string[]; - expandedWorktreeSections?: string[]; folderOrder?: string[]; historyVisibleCount?: number; organizeMode?: SidebarStoreState["organizeMode"]; @@ -177,9 +160,6 @@ export const useSidebarStore = create()( persistedState.width ?? current.width, ), collapsedSections: new Set(persistedState.collapsedSections ?? []), - expandedWorktreeSections: new Set( - persistedState.expandedWorktreeSections ?? [], - ), folderOrder: persistedState.folderOrder ?? [], historyVisibleCount: persistedState.historyVisibleCount ?? current.historyVisibleCount, From e10ba47184f6c4bb9e350dc1281a163b475d676a Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 16 Jul 2026 16:58:32 -0700 Subject: [PATCH 5/5] Refine sidebar worktrees implementation with type safety - Add showSidebarWorktrees to settings rehydration test - Clarify padding calculation comment in SidebarSection - Use AdoptableWorktree named type instead of inline object type --- .../features/settings/settingsStore.test.ts | 1 + .../sidebar/components/SidebarSection.tsx | 1 + .../sidebar/useStartTaskFromWorktree.test.tsx | 213 ++++++++++++++++++ .../src/services/workspace/workspace.ts | 3 +- 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/sidebar/useStartTaskFromWorktree.test.tsx diff --git a/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts index 208280e7bd..1b302ecf8f 100644 --- a/packages/ui/src/features/settings/settingsStore.test.ts +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -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 }), diff --git a/packages/ui/src/features/sidebar/components/SidebarSection.tsx b/packages/ui/src/features/sidebar/components/SidebarSection.tsx index 6d0c3c45f7..eb9a7cf609 100644 --- a/packages/ui/src/features/sidebar/components/SidebarSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarSection.tsx @@ -47,6 +47,7 @@ export function SidebarSection({ className="flex w-full items-center justify-between pl-2 not-hover:aria-expanded:bg-transparent" style={{ marginTop: addSpacingBefore ? "12px" : undefined, + // + 8 matches the base pl-2 padding set via className below. paddingLeft: depth ? `${depth * INDENT_SIZE + 8}px` : undefined, }} onContextMenu={onContextMenu} diff --git a/packages/ui/src/features/sidebar/useStartTaskFromWorktree.test.tsx b/packages/ui/src/features/sidebar/useStartTaskFromWorktree.test.tsx new file mode 100644 index 0000000000..6d5652bc4c --- /dev/null +++ b/packages/ui/src/features/sidebar/useStartTaskFromWorktree.test.tsx @@ -0,0 +1,213 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createTaskMock = vi.hoisted(() => vi.fn()); +const invalidateTasksMock = vi.hoisted(() => vi.fn()); +const toastErrorMock = vi.hoisted(() => vi.fn()); +const setFailedMock = vi.hoisted(() => vi.fn()); +const openTaskMock = vi.hoisted(() => vi.fn()); +const trackMock = vi.hoisted(() => vi.fn()); +const queryFilterMock = vi.hoisted(() => + vi.fn((input: { mainRepoPath: string }) => ({ + queryKey: ["workspace", "listAdoptableWorktrees", input], + })), +); + +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ createTask: createTaskMock }), +})); +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ + workspace: { listAdoptableWorktrees: { queryFilter: queryFilterMock } }, + }), +})); +vi.mock("@posthog/ui/features/tasks/useTaskCrudMutations", () => ({ + useCreateTask: () => ({ invalidateTasks: invalidateTasksMock }), +})); +vi.mock("@posthog/ui/features/notifications/errorDetails", () => ({ + toastError: toastErrorMock, +})); +vi.mock("@posthog/ui/features/provisioning/store", () => ({ + useProvisioningStore: { getState: () => ({ setFailed: setFailedMock }) }, +})); +vi.mock("@posthog/ui/router/useOpenTask", () => ({ + openTask: openTaskMock, +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ + track: trackMock, +})); + +import { useStartTaskFromWorktree } from "./useStartTaskFromWorktree"; + +const MAIN_REPO_PATH = "/repo"; +const BRANCH = "feature/orphan"; + +function fakeTask(): Task { + return { + id: "task-123", + task_number: 1, + slug: "task-123", + title: BRANCH, + description: BRANCH, + created_at: "2026-06-15T00:00:00.000Z", + updated_at: "2026-06-15T00:00:00.000Z", + origin_product: "user_created", + }; +} + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +describe("useStartTaskFromWorktree", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates the task, opens it, tracks the event, and clears the in-flight branch", async () => { + const task = fakeTask(); + createTaskMock.mockImplementationOnce(async (_input, onTaskReady) => { + onTaskReady?.({ task, workspace: null }); + return { success: true, data: { task, workspace: null } }; + }); + + const { result } = renderHook( + () => useStartTaskFromWorktree(MAIN_REPO_PATH), + { + wrapper, + }, + ); + + await act(async () => { + await result.current.startTask(BRANCH); + }); + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + taskDescription: BRANCH, + repoPath: MAIN_REPO_PATH, + workspaceMode: "worktree", + branch: BRANCH, + reuseExistingWorktree: true, + }), + expect.any(Function), + ); + expect(invalidateTasksMock).toHaveBeenCalledWith(task); + expect(openTaskMock).toHaveBeenCalledWith(task); + expect(trackMock).toHaveBeenCalledWith( + "Task created", + expect.objectContaining({ + created_from: "sidebar-worktree", + workspace_mode: "worktree", + }), + ); + expect(queryFilterMock).toHaveBeenCalledWith({ + mainRepoPath: MAIN_REPO_PATH, + }); + expect(toastErrorMock).not.toHaveBeenCalled(); + expect(setFailedMock).not.toHaveBeenCalled(); + await waitFor(() => + expect(result.current.startingBranches.has(BRANCH)).toBe(false), + ); + }); + + it("toasts and does not track when task creation fails validation", async () => { + createTaskMock.mockResolvedValueOnce({ + success: false, + error: "Task description cannot be empty", + failedStep: "validation", + }); + + const { result } = renderHook( + () => useStartTaskFromWorktree(MAIN_REPO_PATH), + { + wrapper, + }, + ); + + await act(async () => { + await result.current.startTask(BRANCH); + }); + + expect(toastErrorMock).toHaveBeenCalledWith( + "Task creation failed", + "Task description cannot be empty", + ); + expect(trackMock).not.toHaveBeenCalled(); + expect(invalidateTasksMock).not.toHaveBeenCalled(); + expect(queryFilterMock).not.toHaveBeenCalled(); + await waitFor(() => + expect(result.current.startingBranches.has(BRANCH)).toBe(false), + ); + }); + + it("marks the task failed and toasts on a provisioning error, but still tracks and invalidates", async () => { + const task = fakeTask(); + createTaskMock.mockImplementationOnce(async (_input, onTaskReady) => { + const output = { + task, + workspace: null, + provisioningError: "git clone failed", + }; + onTaskReady?.(output); + return { success: true, data: output }; + }); + + const { result } = renderHook( + () => useStartTaskFromWorktree(MAIN_REPO_PATH), + { + wrapper, + }, + ); + + await act(async () => { + await result.current.startTask(BRANCH); + }); + + expect(setFailedMock).toHaveBeenCalledWith(task.id, "git clone failed"); + expect(toastErrorMock).toHaveBeenCalledWith( + "Failed to create workspace", + "git clone failed", + ); + // Provisioning failure is still a successful task creation: the create + // event still fires and the adoptable-worktrees list still refetches. + expect(trackMock).toHaveBeenCalled(); + expect(queryFilterMock).toHaveBeenCalledWith({ + mainRepoPath: MAIN_REPO_PATH, + }); + await waitFor(() => + expect(result.current.startingBranches.has(BRANCH)).toBe(false), + ); + }); + + it("toasts and clears the in-flight branch when createTask throws", async () => { + createTaskMock.mockRejectedValueOnce(new Error("network down")); + + const { result } = renderHook( + () => useStartTaskFromWorktree(MAIN_REPO_PATH), + { + wrapper, + }, + ); + + await act(async () => { + await result.current.startTask(BRANCH); + }); + + expect(toastErrorMock).toHaveBeenCalledWith( + "Failed to start task from worktree", + expect.any(Error), + ); + await waitFor(() => + expect(result.current.startingBranches.has(BRANCH)).toBe(false), + ); + }); +}); diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index 626e1ea8c9..e366a53e5d 100644 --- a/packages/workspace-server/src/services/workspace/workspace.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -65,6 +65,7 @@ import type { WorkspaceProvisioning, } from "./ports"; import type { + AdoptableWorktree, BranchChangedPayload, CheckWorktreeBranchInput, CheckWorktreeBranchOutput, @@ -1481,7 +1482,7 @@ export class WorkspaceService extends TypedEventEmitter */ async listAdoptableWorktrees( mainRepoPath: string, - ): Promise> { + ): Promise { const [linkedWorktrees, localStashPath] = await Promise.all([ listLinkedWorktrees(mainRepoPath), this.getLocalWorktreePathIfExists(mainRepoPath),