From c71917429dc9145f9e155d3dfadcf1c3806b4daf Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 5 Aug 2026 11:06:15 +0100 Subject: [PATCH 1/5] feat(tasks): select repositories per space task Generated-By: PostHog Code Task-Id: 7d648577-8338-47ee-a9c4-5a168289074d --- .../core/src/task-detail/taskCreationSaga.ts | 7 +- .../core/src/task-detail/taskInput.test.ts | 24 ++++ .../core/src/task-detail/taskInput.ts | 2 + .../shared/src/task-creation-domain.ts | 1 + .../canvas/components/ChannelHomeComposer.tsx | 69 +++++++++- .../components/TaskRepositoryDialog.tsx | 129 ++++++++++++++++++ .../canvas/components/WebsiteChannelHome.tsx | 2 + .../task-detail/hooks/useTaskCreation.ts | 10 +- 8 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx diff --git a/products/desktop/packages/core/src/task-detail/taskCreationSaga.ts b/products/desktop/packages/core/src/task-detail/taskCreationSaga.ts index bffba3e95a8e..5af70503414f 100644 --- a/products/desktop/packages/core/src/task-detail/taskCreationSaga.ts +++ b/products/desktop/packages/core/src/task-detail/taskCreationSaga.ts @@ -800,10 +800,13 @@ export class TaskCreationSaga extends Saga< input.runtime !== "pi" && !warmPayload?.suppressWarmReuse; const result = await this.deps.posthogClient.createTask({ description, - repository: repository ?? undefined, + repository: input.repositories + ? undefined + : (repository ?? undefined), + repositories: input.repositories, github_integration: input.workspaceMode === "cloud" && - input.cloudRunSource === "signal_report" + (input.cloudRunSource === "signal_report" || input.repositories) ? input.githubIntegrationId : undefined, github_user_integration: diff --git a/products/desktop/packages/core/src/task-detail/taskInput.test.ts b/products/desktop/packages/core/src/task-detail/taskInput.test.ts index e30a5a4c3902..1b4c8f0aba0a 100644 --- a/products/desktop/packages/core/src/task-detail/taskInput.test.ts +++ b/products/desktop/packages/core/src/task-detail/taskInput.test.ts @@ -44,6 +44,30 @@ describe("prepareTaskInput", () => { }); expect(input.customInstructions).toBeUndefined(); }); + + it("preserves task-specific cloud repositories", () => { + const input = prepareTaskInput("do the thing", [], { + workspaceMode: "cloud", + repositories: ["posthog/posthog", "posthog/posthog-js"], + githubIntegrationId: 42, + }); + + expect(input.repositories).toEqual([ + "posthog/posthog", + "posthog/posthog-js", + ]); + expect(input.githubIntegrationId).toBe(42); + }); + + it("uses a selected folder for a repo-optional local task", () => { + const input = prepareTaskInput("do the thing", [], { + workspaceMode: "local", + selectedDirectory: "/code/posthog", + allowNoRepo: true, + }); + + expect(input.repoPath).toBe("/code/posthog"); + }); }); describe("buildWorktreeAdoptionInput", () => { diff --git a/products/desktop/packages/core/src/task-detail/taskInput.ts b/products/desktop/packages/core/src/task-detail/taskInput.ts index 64544f9f3297..8a0e5f246bc7 100644 --- a/products/desktop/packages/core/src/task-detail/taskInput.ts +++ b/products/desktop/packages/core/src/task-detail/taskInput.ts @@ -12,6 +12,7 @@ import type { ExecutionMode } from "@posthog/shared/domain-types"; export interface PrepareTaskInputOptions { selectedDirectory?: string; selectedRepository?: string | null; + repositories?: string[]; githubIntegrationId?: number; githubUserIntegrationId?: string; workspaceMode: WorkspaceMode; @@ -56,6 +57,7 @@ export function prepareTaskInput( filePaths, repoPath: isCloud ? undefined : options.selectedDirectory, repository: isCloud ? options.selectedRepository : undefined, + repositories: isCloud ? options.repositories : undefined, githubIntegrationId: options.githubIntegrationId, githubUserIntegrationId: options.githubUserIntegrationId, workspaceMode: options.workspaceMode, diff --git a/products/desktop/packages/shared/src/task-creation-domain.ts b/products/desktop/packages/shared/src/task-creation-domain.ts index a3e9ca4644b8..bd4ca4230518 100644 --- a/products/desktop/packages/shared/src/task-creation-domain.ts +++ b/products/desktop/packages/shared/src/task-creation-domain.ts @@ -24,6 +24,7 @@ export interface TaskCreationInput { filePaths?: string[]; repoPath?: string; repository?: string | null; + repositories?: string[]; workspaceMode?: WorkspaceMode; branch?: string | null; // When the branch exists only on the remote, opt in to fetching and checking diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index d92522f564e6..2778f3dd1de1 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -1,8 +1,10 @@ +import { FolderOpenIcon, GithubLogoIcon } from "@phosphor-icons/react"; import type { PiModelSelection, PiThinkingLevel, } from "@posthog/core/pi-runtime/piSessionController"; import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; +import { Button } from "@posthog/quill"; import { type AgentRuntime, ANALYTICS_EVENTS } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { useQueryClient } from "@tanstack/react-query"; @@ -42,7 +44,9 @@ import { useTaskCreation } from "../../task-detail/hooks/useTaskCreation"; import { resolveWorkspaceModePreference } from "../../task-detail/hooks/workspaceModePreference"; import { channelFeedQueryKey } from "../hooks/useChannelFeed"; import { useGenerateFreeformCanvas } from "../hooks/useGenerateFreeformCanvas"; +import { useUpdateTaskChannelRepositories } from "../hooks/useTaskChannels"; import type { PendingKickoff } from "./ChannelFeedView"; +import { TaskRepositoryDialog } from "./TaskRepositoryDialog"; export interface ChannelHomeComposerHandle { /** Drop a starter prompt into the editor and apply its mode, if any. */ @@ -55,6 +59,8 @@ interface ChannelHomeComposerProps { channelName?: string; /** Channel CONTEXT.md, attached to the created task as background. */ channelContext?: string; + channelRepositories?: string[]; + channelGithubIntegration?: number | null; onTaskCreated: (task: Task) => void; /** Post an optimistic kickoff to the feed the instant a submit is accepted. */ onPendingStart: (kickoff: PendingKickoff) => void; @@ -76,6 +82,8 @@ export const ChannelHomeComposer = forwardRef< channelId, channelName, channelContext, + channelRepositories = [], + channelGithubIntegration = null, onTaskCreated, onPendingStart, onPendingEnd, @@ -178,6 +186,20 @@ export const ChannelHomeComposer = forwardRef< const [selectedCustomImageId, setSelectedCustomImageId] = useState< string | null >(null); + const [repositoryDialogOpen, setRepositoryDialogOpen] = useState(false); + const [taskRepositories, setTaskRepositories] = useState(channelRepositories); + const [taskGithubIntegration, setTaskGithubIntegration] = useState< + number | null + >(channelGithubIntegration); + const [taskFolder, setTaskFolder] = useState(""); + const updateChannelRepositories = useUpdateTaskChannelRepositories(); + const channelRepositoriesKey = channelRepositories.join("\n"); + useEffect(() => { + setTaskRepositories( + channelRepositoriesKey ? channelRepositoriesKey.split("\n") : [], + ); + setTaskGithubIntegration(channelGithubIntegration); + }, [channelRepositoriesKey, channelGithubIntegration]); const setWorkspaceMode = useCallback( (mode: WorkspaceMode) => { setWorkspaceModeState(mode); @@ -322,7 +344,12 @@ export const ChannelHomeComposer = forwardRef< const { isCreatingTask, canSubmit, handleSubmit } = useTaskCreation({ editorRef, sessionId, - selectedDirectory: "", + selectedDirectory: taskFolder, + repositories: workspaceMode === "cloud" ? taskRepositories : undefined, + githubIntegrationId: + workspaceMode === "cloud" + ? (taskGithubIntegration ?? undefined) + : undefined, workspaceMode, sandboxEnvironmentId: workspaceMode === "cloud" && selectedCloudEnvId @@ -474,9 +501,49 @@ export const ChannelHomeComposer = forwardRef< size="1" disabled={isBusy} /> + )} + { + setTaskRepositories(selection.repositories); + setTaskGithubIntegration(selection.integrationId); + setTaskFolder(selection.folder); + if (selection.saveToSpace && workspaceMode === "cloud") { + updateChannelRepositories.mutate({ + channelId, + githubIntegration: selection.integrationId, + repositories: selection.repositories, + }); + } + }} + /> + void; + cloud: boolean; + repositories: string[]; + integrationId: number | null; + folder: string; + onApply: (selection: { + repositories: string[]; + integrationId: number | null; + folder: string; + saveToSpace: boolean; + }) => void; +} + +export function TaskRepositoryDialog({ + open, + onOpenChange, + cloud, + repositories, + integrationId, + folder, + onApply, +}: TaskRepositoryDialogProps) { + const [draftRepositories, setDraftRepositories] = useState(repositories); + const [draftIntegrationId, setDraftIntegrationId] = useState(integrationId); + const [draftFolder, setDraftFolder] = useState(folder); + const [saveToSpace, setSaveToSpace] = useState(false); + + useEffect(() => { + if (!open) return; + setDraftRepositories(repositories); + setDraftIntegrationId(integrationId); + setDraftFolder(folder); + setSaveToSpace(false); + }, [open, repositories, integrationId, folder]); + + return ( + + + + + {cloud ? "Add repositories" : "Select folder"} + + + {cloud + ? "Choose the repositories this task can work across." + : "Choose the local folder this task should work in."} + + + + {cloud ? ( +
+ { + setDraftRepositories(next); + setDraftIntegrationId(nextIntegrationId); + }} + /> + +
+ ) : ( + + )} +
+ + + + +
+
+ ); +} diff --git a/products/desktop/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/products/desktop/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 293a71d55874..f52c8e478368 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -297,6 +297,8 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { channelId={channelId} channelName={channelName} channelContext={channelContext} + channelRepositories={channel?.repositories} + channelGithubIntegration={channel?.github_integration} onTaskCreated={onTaskCreated} onPendingStart={addPending} onPendingEnd={removePending} diff --git a/products/desktop/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/products/desktop/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 9bbfaf88cd16..fd4ee5e42f5d 100644 --- a/products/desktop/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/products/desktop/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -70,6 +70,7 @@ interface UseTaskCreationOptions { sessionId: string; selectedDirectory: string; selectedRepository?: string | null; + repositories?: string[]; githubIntegrationId?: number; githubUserIntegrationId?: string; workspaceMode: WorkspaceMode; @@ -174,6 +175,7 @@ export function useTaskCreation({ sessionId, selectedDirectory, selectedRepository, + repositories, githubIntegrationId, githubUserIntegrationId, workspaceMode, @@ -364,10 +366,11 @@ export function useTaskCreation({ adapter, ); const input = prepareTaskInput(serializedContent, filePaths, { - // In channels chat-box mode no repo is attached up front, even if a - // directory/repo is lingering in the persisted picker state. - selectedDirectory: allowNoRepo ? undefined : selectedDirectory, + // Repo-optional surfaces may still supply an explicit task folder or + // repository selection; otherwise creation falls back to scratch. + selectedDirectory: selectedDirectory || undefined, selectedRepository: allowNoRepo ? null : selectedRepository, + repositories, githubIntegrationId, githubUserIntegrationId, workspaceMode, @@ -547,6 +550,7 @@ export function useTaskCreation({ sessionId, selectedDirectory, selectedRepository, + repositories, githubIntegrationId, githubUserIntegrationId, workspaceMode, From 987568a9da6263fe3f760a8dfab971705e1980ed Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 5 Aug 2026 11:06:17 +0100 Subject: [PATCH 2/5] feat(tasks): add repository picker to new session Generated-By: PostHog Code Task-Id: 7d648577-8338-47ee-a9c4-5a168289074d --- .../canvas/components/ChannelHomeComposer.tsx | 39 ++++------- .../components/TaskRepositoryDialog.tsx | 43 ++++++++++++ .../canvas/components/WebsiteNewTask.test.tsx | 16 +++++ .../canvas/components/WebsiteNewTask.tsx | 10 ++- .../task-detail/components/TaskInput.tsx | 67 ++++++++++++++++++- 5 files changed, 146 insertions(+), 29 deletions(-) diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index 2778f3dd1de1..eadcfaed9c86 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -1,10 +1,8 @@ -import { FolderOpenIcon, GithubLogoIcon } from "@phosphor-icons/react"; import type { PiModelSelection, PiThinkingLevel, } from "@posthog/core/pi-runtime/piSessionController"; import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; -import { Button } from "@posthog/quill"; import { type AgentRuntime, ANALYTICS_EVENTS } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { useQueryClient } from "@tanstack/react-query"; @@ -46,7 +44,10 @@ import { channelFeedQueryKey } from "../hooks/useChannelFeed"; import { useGenerateFreeformCanvas } from "../hooks/useGenerateFreeformCanvas"; import { useUpdateTaskChannelRepositories } from "../hooks/useTaskChannels"; import type { PendingKickoff } from "./ChannelFeedView"; -import { TaskRepositoryDialog } from "./TaskRepositoryDialog"; +import { + TaskRepositoryChip, + TaskRepositoryDialog, +} from "./TaskRepositoryDialog"; export interface ChannelHomeComposerHandle { /** Drop a starter prompt into the editor and apply its mode, if any. */ @@ -70,10 +71,10 @@ interface ChannelHomeComposerProps { // The prompt box at the bottom of a channel's homepage. A trimmed-down sibling // of TaskInput: it reuses the same task-creation pipeline (model/mode/reasoning -// preview config + useTaskCreation) but drops the repo/branch pickers — channel -// tasks run repo-less and the agent attaches a repo lazily if it needs one. The -// starter-prompt suggestions render in the parent above the box; this owns the -// local/cloud selector. +// preview config + useTaskCreation) but drops the branch picker. Tasks default +// to the space's repositories; the chip beside the local/cloud selector swaps +// in a task-specific repository or folder selection. The starter-prompt +// suggestions render in the parent above the box; this owns the selector row. export const ChannelHomeComposer = forwardRef< ChannelHomeComposerHandle, ChannelHomeComposerProps @@ -501,25 +502,13 @@ export const ChannelHomeComposer = forwardRef< size="1" disabled={isBusy} /> - + onOpen={() => setRepositoryDialogOpen(true)} + /> )} diff --git a/products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx b/products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx index 1379d77f6e87..d4c4a5b6bec0 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx @@ -29,6 +29,49 @@ interface TaskRepositoryDialogProps { }) => void; } +/** + * The task's repository (cloud) or folder (local) selection — a chip for the + * composer's selector row, drawn like the WorkspaceModeSelect beside it. + * Clicking it opens the TaskRepositoryDialog. + */ +export function TaskRepositoryChip({ + cloud, + repositoryCount, + hasFolder, + disabled, + onOpen, +}: { + cloud: boolean; + repositoryCount: number; + hasFolder: boolean; + disabled: boolean; + onOpen: () => void; +}) { + const label = cloud + ? repositoryCount > 0 + ? `${repositoryCount} ${repositoryCount === 1 ? "repository" : "repositories"}` + : "Add repositories…" + : hasFolder + ? "Folder selected" + : "Select folder…"; + + return ( + + ); +} + export function TaskRepositoryDialog({ open, onOpenChange, diff --git a/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx index 45babfd347f6..9d90701c7b37 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx @@ -35,6 +35,22 @@ vi.mock("@posthog/ui/features/task-detail/components/TaskInput", () => ({ }, })); +// The raw channel rows WebsiteNewTask reads repository defaults from. +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", () => ({ + useTaskChannels: () => ({ + channels: [ + { + id: "chan-1", + name: "project-bluebird", + channel_type: "public", + starred: false, + }, + ], + }), +})); + +// SpaceSelect (the spaceSelector chip) reads useChannels; keep its dependency +// chain inert under the stubbed TaskInput. vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ useChannels: () => ({ channels: [ diff --git a/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index 6b28e70cc1e2..29e5ed2967df 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -4,10 +4,10 @@ import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTas import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { ChannelContextPanel } from "@posthog/ui/features/canvas/components/ChannelContextPanel"; import { SpaceSelect } from "@posthog/ui/features/canvas/components/SpaceSelect"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { useTaskChannels } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -29,8 +29,10 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { const view = useAppView(); const queryClient = useQueryClient(); const { fileTask } = useChannelTaskMutations(); - const { channels } = useChannels(); - const channelName = channels.find((c) => c.id === channelId)?.name; + // The raw channel row also carries the space's repository defaults. + const { channels } = useTaskChannels(); + const channel = channels.find((c) => c.id === channelId); + const channelName = channel?.name; // Surface the channel breadcrumb in the shared header, same as the other // channel scenes ("# channel / New task"). @@ -145,6 +147,8 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { channelId={channelId} channelContextId={channelId} allowNoRepo + channelRepositories={channel?.repositories} + channelGithubIntegration={channel?.github_integration} // So a prompt handed to openTaskInput survives routing into a channel. initialPrompt={view.initialPrompt} initialPromptKey={view.taskInputRequestId} diff --git a/products/desktop/packages/ui/src/features/task-detail/components/TaskInput.tsx b/products/desktop/packages/ui/src/features/task-detail/components/TaskInput.tsx index d3da9b9a25b0..45e6928ab19d 100644 --- a/products/desktop/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/products/desktop/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -15,6 +15,11 @@ import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { ButtonGroup } from "@posthog/quill"; import { type AgentRuntime, ANALYTICS_EVENTS } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { + TaskRepositoryChip, + TaskRepositoryDialog, +} from "@posthog/ui/features/canvas/components/TaskRepositoryDialog"; +import { useUpdateTaskChannelRepositories } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import type { TaskInputReportAssociation } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { useTaskInputPrefillStore } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; @@ -137,6 +142,8 @@ interface TaskInputProps { * needs a repo and attaches one lazily. */ allowNoRepo?: boolean; + channelRepositories?: string[]; + channelGithubIntegration?: number | null; /** * Channels new-task starter prompts. When provided, a column of suggestion * cards renders below the input while it's empty; clicking one fills the @@ -179,6 +186,8 @@ export function TaskInput({ channelId, channelContextId, allowNoRepo, + channelRepositories = [], + channelGithubIntegration = null, suggestions, onSuggestionSelect, onContextChipClick, @@ -204,6 +213,20 @@ export function TaskInput({ ); const selectedDirectory = useActiveRepoStore((s) => s.path); const setSelectedDirectory = useActiveRepoStore((s) => s.setPath); + const [repositoryDialogOpen, setRepositoryDialogOpen] = useState(false); + const [taskRepositories, setTaskRepositories] = useState(channelRepositories); + const [taskGithubIntegration, setTaskGithubIntegration] = useState< + number | null + >(channelGithubIntegration); + const [taskFolder, setTaskFolder] = useState(""); + const updateChannelRepositories = useUpdateTaskChannelRepositories(); + const channelRepositoriesKey = channelRepositories.join("\n"); + useEffect(() => { + setTaskRepositories( + channelRepositoriesKey ? channelRepositoriesKey.split("\n") : [], + ); + setTaskGithubIntegration(channelGithubIntegration); + }, [channelRepositoriesKey, channelGithubIntegration]); // Inline file preview opened from the command palette's file search. const previewFile = useFileSearchStore((s) => s.previewFile); const closePreviewFile = useFileSearchStore((s) => s.closePreview); @@ -944,8 +967,14 @@ export function TaskInput({ } = useTaskCreation({ editorRef, sessionId, - selectedDirectory, + selectedDirectory: allowNoRepo ? taskFolder : selectedDirectory, selectedRepository: selectedCloudRepository, + repositories: + allowNoRepo && workspaceMode === "cloud" ? taskRepositories : undefined, + githubIntegrationId: + allowNoRepo && workspaceMode === "cloud" + ? (taskGithubIntegration ?? undefined) + : undefined, githubUserIntegrationId: selectedGithubUserIntegrationId, workspaceMode: effectiveWorkspaceMode, branch: branchForTaskCreation, @@ -1239,6 +1268,15 @@ export function TaskInput({ onCustomImageChange={setSelectedCustomImageId} size="1" /> + {allowNoRepo && ( + setRepositoryDialogOpen(true)} + /> + )} {!allowNoRepo && workspaceMode === "worktree" && ( + {allowNoRepo && ( + { + setTaskRepositories(selection.repositories); + setTaskGithubIntegration(selection.integrationId); + setTaskFolder(selection.folder); + if ( + selection.saveToSpace && + channelId && + workspaceMode === "cloud" + ) { + updateChannelRepositories.mutate({ + channelId, + githubIntegration: selection.integrationId, + repositories: selection.repositories, + }); + } + }} + /> + )} + { From c7eff25c6de45a49c2a841f1f8a011593f4ef566 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 5 Aug 2026 11:06:19 +0100 Subject: [PATCH 3/5] fix(tasks): snapshot repository dialog draft on open during render Generated-By: PostHog Code Task-Id: e301f85c-0c79-4e9a-a42c-9d2c32da40d8 --- .../components/TaskRepositoryDialog.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx b/products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx index d4c4a5b6bec0..8054db75dfc3 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/TaskRepositoryDialog.tsx @@ -11,7 +11,7 @@ import { DialogTitle, } from "@posthog/quill"; import { FolderPicker } from "@posthog/ui/features/folder-picker/FolderPicker"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { RepositoriesField } from "./RepositoriesField"; interface TaskRepositoryDialogProps { @@ -86,13 +86,18 @@ export function TaskRepositoryDialog({ const [draftFolder, setDraftFolder] = useState(folder); const [saveToSpace, setSaveToSpace] = useState(false); - useEffect(() => { - if (!open) return; - setDraftRepositories(repositories); - setDraftIntegrationId(integrationId); - setDraftFolder(folder); - setSaveToSpace(false); - }, [open, repositories, integrationId, folder]); + // Snapshot the current selection into the draft when the dialog opens, + // adjusted during render so prop churn while open can't clobber edits. + const [wasOpen, setWasOpen] = useState(open); + if (open !== wasOpen) { + setWasOpen(open); + if (open) { + setDraftRepositories(repositories); + setDraftIntegrationId(integrationId); + setDraftFolder(folder); + setSaveToSpace(false); + } + } return ( From f69303de4166981c1666f0ad67eb0d30649d4f22 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 6 Aug 2026 10:14:40 +0100 Subject: [PATCH 4/5] fix(tasks): share the task repository draft across space composers Each composer held the repository/folder pick in local component state, so a selection made on the new-task screen vanished when navigating to the space home. The draft now lives in a per-space zustand store shared by both surfaces, and a failed save-to-space surfaces a toast instead of rolling back silently. Generated-By: PostHog Code Task-Id: 11cdce8b-166b-4996-8b91-674ab1e9d675 --- .../canvas/components/ChannelHomeComposer.tsx | 54 +++++++++++------- .../stores/taskRepositoryDraftStore.test.ts | 54 ++++++++++++++++++ .../canvas/stores/taskRepositoryDraftStore.ts | 46 +++++++++++++++ .../task-detail/components/TaskInput.tsx | 56 ++++++++++++------- 4 files changed, 170 insertions(+), 40 deletions(-) create mode 100644 products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.test.ts create mode 100644 products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.ts diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index eadcfaed9c86..f25aeaa53a30 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -15,6 +15,7 @@ import { useState, } from "react"; import { useConnectivity } from "../../../hooks/useConnectivity"; +import { toast } from "../../../primitives/toast"; import { track } from "../../../shell/analytics"; import { useFeatureFlag } from "../../feature-flags/useFeatureFlag"; import { useFeatureFlagsLoaded } from "../../feature-flags/useFeatureFlagsLoaded"; @@ -43,6 +44,10 @@ import { resolveWorkspaceModePreference } from "../../task-detail/hooks/workspac import { channelFeedQueryKey } from "../hooks/useChannelFeed"; import { useGenerateFreeformCanvas } from "../hooks/useGenerateFreeformCanvas"; import { useUpdateTaskChannelRepositories } from "../hooks/useTaskChannels"; +import { + resolveTaskRepositoryDraft, + useTaskRepositoryDraftStore, +} from "../stores/taskRepositoryDraftStore"; import type { PendingKickoff } from "./ChannelFeedView"; import { TaskRepositoryChip, @@ -188,19 +193,20 @@ export const ChannelHomeComposer = forwardRef< string | null >(null); const [repositoryDialogOpen, setRepositoryDialogOpen] = useState(false); - const [taskRepositories, setTaskRepositories] = useState(channelRepositories); - const [taskGithubIntegration, setTaskGithubIntegration] = useState< - number | null - >(channelGithubIntegration); - const [taskFolder, setTaskFolder] = useState(""); + const repositoryDraft = useTaskRepositoryDraftStore( + (s) => s.drafts[channelId], + ); + const setRepositoryDraft = useTaskRepositoryDraftStore((s) => s.setDraft); + const { + repositories: taskRepositories, + githubIntegration: taskGithubIntegration, + folder: taskFolder, + } = resolveTaskRepositoryDraft( + repositoryDraft, + channelRepositories, + channelGithubIntegration, + ); const updateChannelRepositories = useUpdateTaskChannelRepositories(); - const channelRepositoriesKey = channelRepositories.join("\n"); - useEffect(() => { - setTaskRepositories( - channelRepositoriesKey ? channelRepositoriesKey.split("\n") : [], - ); - setTaskGithubIntegration(channelGithubIntegration); - }, [channelRepositoriesKey, channelGithubIntegration]); const setWorkspaceMode = useCallback( (mode: WorkspaceMode) => { setWorkspaceModeState(mode); @@ -520,15 +526,23 @@ export const ChannelHomeComposer = forwardRef< integrationId={taskGithubIntegration} folder={taskFolder} onApply={(selection) => { - setTaskRepositories(selection.repositories); - setTaskGithubIntegration(selection.integrationId); - setTaskFolder(selection.folder); + setRepositoryDraft(channelId, { + repositories: selection.repositories, + githubIntegration: selection.integrationId, + folder: selection.folder, + }); if (selection.saveToSpace && workspaceMode === "cloud") { - updateChannelRepositories.mutate({ - channelId, - githubIntegration: selection.integrationId, - repositories: selection.repositories, - }); + updateChannelRepositories.mutate( + { + channelId, + githubIntegration: selection.integrationId, + repositories: selection.repositories, + }, + { + onError: () => + toast.error("Couldn't save repositories to the space"), + }, + ); } }} /> diff --git a/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.test.ts b/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.test.ts new file mode 100644 index 000000000000..b909c58b5ac4 --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + resolveTaskRepositoryDraft, + useTaskRepositoryDraftStore, +} from "./taskRepositoryDraftStore"; + +describe("taskRepositoryDraftStore", () => { + beforeEach(() => { + useTaskRepositoryDraftStore.setState({ drafts: {} }); + }); + + it("falls back to the space defaults when no draft exists", () => { + const resolved = resolveTaskRepositoryDraft( + undefined, + ["posthog/posthog"], + 7, + ); + expect(resolved).toEqual({ + repositories: ["posthog/posthog"], + githubIntegration: 7, + folder: "", + }); + }); + + it("shares a draft set on one surface with every composer in the space", () => { + const { setDraft } = useTaskRepositoryDraftStore.getState(); + setDraft("chan-1", { + repositories: ["posthog/posthog-js"], + githubIntegration: 3, + folder: "", + }); + + const draft = useTaskRepositoryDraftStore.getState().drafts["chan-1"]; + expect(resolveTaskRepositoryDraft(draft, ["posthog/posthog"], 7)).toEqual({ + repositories: ["posthog/posthog-js"], + githubIntegration: 3, + folder: "", + }); + expect(useTaskRepositoryDraftStore.getState().drafts["chan-2"]).toBe( + undefined, + ); + }); + + it("keeps an emptied draft instead of backfilling from the defaults", () => { + const resolved = resolveTaskRepositoryDraft( + { repositories: [], githubIntegration: null, folder: "/tmp/work" }, + ["posthog/posthog"], + 7, + ); + expect(resolved.repositories).toEqual([]); + expect(resolved.githubIntegration).toBe(null); + expect(resolved.folder).toBe("/tmp/work"); + }); +}); diff --git a/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.ts b/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.ts new file mode 100644 index 000000000000..a5e84be71f95 --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.ts @@ -0,0 +1,46 @@ +import { create } from "zustand"; + +/** A task-level repository/folder pick made in a composer's repository dialog. */ +export interface TaskRepositoryDraft { + repositories: string[]; + githubIntegration: number | null; + folder: string; +} + +interface TaskRepositoryDraftState { + /** Keyed by backend channel UUID, so every composer in a space shares one draft. */ + drafts: Record; + setDraft: (channelId: string, draft: TaskRepositoryDraft) => void; +} + +// The next-task repository selection for each space. Held outside the +// composers so a pick made on one surface (space home, new-task screen) +// survives navigation and shows on the others, the way the prompt draft does. +export const useTaskRepositoryDraftStore = create()( + (set) => ({ + drafts: {}, + setDraft: (channelId, draft) => + set((state) => ({ drafts: { ...state.drafts, [channelId]: draft } })), + }), +); + +/** + * The selection a composer should show: the space draft when one exists, + * otherwise the space's saved defaults. An existing draft wins wholesale — + * an emptied repository list or cleared integration is a deliberate pick, + * not a gap to backfill from the defaults. + */ +export function resolveTaskRepositoryDraft( + draft: TaskRepositoryDraft | undefined, + channelRepositories: string[], + channelGithubIntegration: number | null, +): TaskRepositoryDraft { + if (draft) { + return draft; + } + return { + repositories: channelRepositories, + githubIntegration: channelGithubIntegration, + folder: "", + }; +} diff --git a/products/desktop/packages/ui/src/features/task-detail/components/TaskInput.tsx b/products/desktop/packages/ui/src/features/task-detail/components/TaskInput.tsx index 45e6928ab19d..404d445465cb 100644 --- a/products/desktop/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/products/desktop/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -20,6 +20,10 @@ import { TaskRepositoryDialog, } from "@posthog/ui/features/canvas/components/TaskRepositoryDialog"; import { useUpdateTaskChannelRepositories } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { + resolveTaskRepositoryDraft, + useTaskRepositoryDraftStore, +} from "@posthog/ui/features/canvas/stores/taskRepositoryDraftStore"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import type { TaskInputReportAssociation } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { useTaskInputPrefillStore } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; @@ -214,19 +218,23 @@ export function TaskInput({ const selectedDirectory = useActiveRepoStore((s) => s.path); const setSelectedDirectory = useActiveRepoStore((s) => s.setPath); const [repositoryDialogOpen, setRepositoryDialogOpen] = useState(false); - const [taskRepositories, setTaskRepositories] = useState(channelRepositories); - const [taskGithubIntegration, setTaskGithubIntegration] = useState< - number | null - >(channelGithubIntegration); - const [taskFolder, setTaskFolder] = useState(""); + // "" only on channel-less repo-optional surfaces (none today); a real space + // id keys the draft shared with the space home composer. + const repositoryDraftKey = channelId ?? ""; + const repositoryDraft = useTaskRepositoryDraftStore( + (s) => s.drafts[repositoryDraftKey], + ); + const setRepositoryDraft = useTaskRepositoryDraftStore((s) => s.setDraft); + const { + repositories: taskRepositories, + githubIntegration: taskGithubIntegration, + folder: taskFolder, + } = resolveTaskRepositoryDraft( + repositoryDraft, + channelRepositories, + channelGithubIntegration, + ); const updateChannelRepositories = useUpdateTaskChannelRepositories(); - const channelRepositoriesKey = channelRepositories.join("\n"); - useEffect(() => { - setTaskRepositories( - channelRepositoriesKey ? channelRepositoriesKey.split("\n") : [], - ); - setTaskGithubIntegration(channelGithubIntegration); - }, [channelRepositoriesKey, channelGithubIntegration]); // Inline file preview opened from the command palette's file search. const previewFile = useFileSearchStore((s) => s.previewFile); const closePreviewFile = useFileSearchStore((s) => s.closePreview); @@ -1634,19 +1642,27 @@ export function TaskInput({ integrationId={taskGithubIntegration} folder={taskFolder} onApply={(selection) => { - setTaskRepositories(selection.repositories); - setTaskGithubIntegration(selection.integrationId); - setTaskFolder(selection.folder); + setRepositoryDraft(repositoryDraftKey, { + repositories: selection.repositories, + githubIntegration: selection.integrationId, + folder: selection.folder, + }); if ( selection.saveToSpace && channelId && workspaceMode === "cloud" ) { - updateChannelRepositories.mutate({ - channelId, - githubIntegration: selection.integrationId, - repositories: selection.repositories, - }); + updateChannelRepositories.mutate( + { + channelId, + githubIntegration: selection.integrationId, + repositories: selection.repositories, + }, + { + onError: () => + toast.error("Couldn't save repositories to the space"), + }, + ); } }} /> From 7c2e681eebbde0942786dd1b16be59fd7de14d0d Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 6 Aug 2026 13:39:35 +0100 Subject: [PATCH 5/5] fix(tasks): clear the task repository draft once its task is created The shared draft outlived the task it was picked for, silently overriding the space defaults for every later task. Clear it on successful creation, mirroring the prompt draft's lifecycle; the dialog's "save to space" checkbox remains the durable path. Generated-By: PostHog Code Task-Id: 11cdce8b-166b-4996-8b91-674ab1e9d675 --- .../stores/taskRepositoryDraftStore.test.ts | 17 +++++++++++++++++ .../canvas/stores/taskRepositoryDraftStore.ts | 12 ++++++++++++ .../task-detail/hooks/useTaskCreation.ts | 7 +++++++ 3 files changed, 36 insertions(+) diff --git a/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.test.ts b/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.test.ts index b909c58b5ac4..3045dcad8ce1 100644 --- a/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.test.ts +++ b/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.test.ts @@ -41,6 +41,23 @@ describe("taskRepositoryDraftStore", () => { ); }); + it("falls back to the space defaults once the consumed draft is cleared", () => { + const { setDraft, clearDraft } = useTaskRepositoryDraftStore.getState(); + setDraft("chan-1", { + repositories: ["posthog/posthog-js"], + githubIntegration: 3, + folder: "", + }); + clearDraft("chan-1"); + + const draft = useTaskRepositoryDraftStore.getState().drafts["chan-1"]; + expect(resolveTaskRepositoryDraft(draft, ["posthog/posthog"], 7)).toEqual({ + repositories: ["posthog/posthog"], + githubIntegration: 7, + folder: "", + }); + }); + it("keeps an emptied draft instead of backfilling from the defaults", () => { const resolved = resolveTaskRepositoryDraft( { repositories: [], githubIntegration: null, folder: "/tmp/work" }, diff --git a/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.ts b/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.ts index a5e84be71f95..ac436afda938 100644 --- a/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.ts +++ b/products/desktop/packages/ui/src/features/canvas/stores/taskRepositoryDraftStore.ts @@ -11,16 +11,28 @@ interface TaskRepositoryDraftState { /** Keyed by backend channel UUID, so every composer in a space shares one draft. */ drafts: Record; setDraft: (channelId: string, draft: TaskRepositoryDraft) => void; + /** Drop a consumed draft so the next task starts from the space defaults again. */ + clearDraft: (channelId: string) => void; } // The next-task repository selection for each space. Held outside the // composers so a pick made on one surface (space home, new-task screen) // survives navigation and shows on the others, the way the prompt draft does. +// Like the prompt draft, it lives until the task it was picked for is created; +// the dialog's "save to space" checkbox is the durable path. export const useTaskRepositoryDraftStore = create()( (set) => ({ drafts: {}, setDraft: (channelId, draft) => set((state) => ({ drafts: { ...state.drafts, [channelId]: draft } })), + clearDraft: (channelId) => + set((state) => { + if (!(channelId in state.drafts)) { + return state; + } + const { [channelId]: _dropped, ...drafts } = state.drafts; + return { drafts }; + }), }), ); diff --git a/products/desktop/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/products/desktop/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index fd4ee5e42f5d..b246b6618b7d 100644 --- a/products/desktop/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/products/desktop/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -21,6 +21,7 @@ import { } from "@posthog/shared"; import type { ExecutionMode, Task } from "@posthog/shared/domain-types"; import { useTaskChannels } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useTaskRepositoryDraftStore } from "@posthog/ui/features/canvas/stores/taskRepositoryDraftStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { useTaskInputPrefillStore } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { navigateToTaskPending } from "@posthog/ui/router/navigationBridge"; @@ -494,6 +495,12 @@ export function useTaskCreation({ if (!contentOverride) { useDraftStore.getState().actions.setDraft(sessionId, null); } + // The task-level repository/folder pick is consumed by this task; + // the next one starts from the space defaults again ("save to + // space" is the durable path). + if (allowNoRepo && channelId) { + useTaskRepositoryDraftStore.getState().clearDraft(channelId); + } void trackTaskCreated(input, selectedDirectory, hostClient); // Repo-less channel tasks create no workspace row (the agent runs in // a scratch dir surfaced as a synthetic workspace), so the normal