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..aa6d3d6dc1 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,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(); + }); +}); diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 73bc9550d8..63902749ca 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -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 = { 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..049e775170 --- /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 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"); + }); +}); diff --git a/packages/core/src/task-detail/taskService.ts b/packages/core/src/task-detail/taskService.ts index 0989046c37..78e6e0c25f 100644 --- a/packages/core/src/task-detail/taskService.ts +++ b/packages/core/src/task-detail/taskService.ts @@ -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, 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 efe590fb7f..984314b2b1 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/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 ( + + + { ["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/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index e8181760bb..161ed7a11a 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -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; @@ -447,6 +453,11 @@ export const useSettingsStore = create()( setConversationCollapseMode: (mode) => set({ conversationCollapseMode: mode }), + // Sidebar + showSidebarWorktrees: false, + 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 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/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 2c69285b71..eb9a7cf609 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"; @@ -17,6 +18,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 +34,7 @@ export function SidebarSection({ onNewTask, newTaskTooltip, dragHandleRef, + depth, }: SidebarSectionProps) { const [isHovered, setIsHovered] = useState(false); @@ -43,6 +47,8 @@ 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} 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..6124cf8115 100644 --- a/packages/ui/src/features/sidebar/components/TaskListView.tsx +++ b/packages/ui/src/features/sidebar/components/TaskListView.tsx @@ -15,8 +15,10 @@ 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"; 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"; @@ -188,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"; @@ -369,6 +374,12 @@ export function TaskListView({ /> )) )} + {folder && showSidebarWorktrees && ( + + )} ); 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.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/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..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, @@ -1472,6 +1473,47 @@ 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), + ]); + + // 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 && + !registeredFolderPaths.has(wt.worktreePath) && + !occupiedPaths.has(wt.worktreePath), + ) + .map((wt) => ({ + worktreePath: wt.worktreePath, + // Narrowed by the branch !== null filter above. + branch: wt.branch as string, + })); + } + async deleteWorktree( mainRepoPath: string, worktreePath: string,