diff --git a/packages/shared/src/workspace-domain.ts b/packages/shared/src/workspace-domain.ts index 4235be5f46..5538b0a1db 100644 --- a/packages/shared/src/workspace-domain.ts +++ b/packages/shared/src/workspace-domain.ts @@ -35,6 +35,12 @@ export const workspaceSchema = z.object({ baseBranch: z.string().nullable(), linkedBranch: z.string().nullable(), createdAt: z.string(), + /** + * Synthetic workspace for a repo-less channel task: its folderPath is a + * scratch dir, not a registered folder. Marks it so callers (e.g. the + * navigation task binder) don't try to register it as a folder or git-init it. + */ + isScratch: z.boolean().optional(), }); export type WorktreeInfo = z.infer; diff --git a/packages/ui/src/features/canvas/channelTaskSuggestions.ts b/packages/ui/src/features/canvas/channelTaskSuggestions.ts new file mode 100644 index 0000000000..5529b1447e --- /dev/null +++ b/packages/ui/src/features/canvas/channelTaskSuggestions.ts @@ -0,0 +1,93 @@ +import { + Bug, + ChartBar, + ChartLine, + ChatCircleText, + Cube, + CurrencyDollar, + Flask, + Wrench, +} from "@phosphor-icons/react"; +import type { SuggestedPrompt } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; + +// Starter prompts shown as cards on the channels (project-bluebird) new-task +// screen. Clicking a card drops its `prompt` into the composer, ready to +// edit/send. Each prompt ends with a "User input:" block of fill-in lines the +// user completes before sending. Channels-only — the /code new-task screen +// keeps its discovery suggestions. Card styling mirrors SuggestedTaskCard +// (icon badge + title + description); the icon/color follow the same +// `var(---N)` token scheme. +export const CHANNEL_TASK_SUGGESTIONS: SuggestedPrompt[] = [ + { + label: "Debug a user issue", + description: "Trace a specific user's events, replays, and errors", + icon: Bug, + color: "red", + mode: "auto", + prompt: + "Help me debug an issue a specific user is hitting. Pull their recent events, session replays, and errors, then figure out what went wrong.\n\n\nUser input:\n- Describe the user issue:\n- User identifier (distinct ID, email address, etc):", + }, + { + label: "Run a feature analysis", + description: "Adoption, engagement, and retention of a feature", + icon: ChartLine, + color: "blue", + mode: "auto", + prompt: + "Analyze how a feature is performing — adoption, engagement, and retention of users who use it vs. those who don't.\n\n\nUser input:\n- Feature to analyze:\n- Time period (optional):", + }, + { + label: "Understand revenue patterns", + description: "Trends over time, by plan, and by cohort", + icon: CurrencyDollar, + color: "green", + mode: "auto", + prompt: + "Analyze our revenue trends — break it down over time, by plan, and by cohort, and call out notable changes and likely drivers.\n\n\nUser input:\n- What revenue question are you trying to answer:\n- Time period (optional):", + }, + { + label: "Summarize product usage", + description: "Top events, active users, and key funnels", + icon: ChartBar, + color: "violet", + mode: "auto", + prompt: + "Summarize how our product is being used — top events, active users, key funnels, and notable trends.\n\n\nUser input:\n- Product area or feature to focus on (optional):\n- Time period (optional):", + }, + { + label: "Summarize user & agent feedback", + description: "Common themes across recent feedback", + icon: ChatCircleText, + color: "amber", + mode: "auto", + prompt: + "Summarize recent user and support/agent feedback — surface the common themes, complaints, and requests.\n\n\nUser input:\n- Feedback source or topic to focus on:\n- Time period (optional):", + }, + { + label: "Interpret experiment results", + description: "Significance and what to do next", + icon: Flask, + color: "purple", + mode: "auto", + prompt: + "Interpret the results of an experiment — explain what the metrics show, whether it's significant, and what to do next.\n\n\nUser input:\n- Experiment name or key:\n- What decision are you trying to make (optional):", + }, + { + label: "Fix a bug", + description: "Track down and fix a problem in the code", + icon: Wrench, + color: "orange", + mode: "plan", + prompt: + "Help me fix a bug — track down the root cause in the code and implement a fix. Open a PR if appropriate.\n\n\nUser input:\n- Describe the bug / what's going wrong:\n- Steps to reproduce (optional):\n- Where it happens (file, page, area — optional):", + }, + { + label: "Build a new feature", + description: "Design and implement something new", + icon: Cube, + color: "teal", + mode: "plan", + prompt: + "Help me build a new feature — propose an approach, then implement it. Open a PR if appropriate.\n\n\nUser input:\n- Describe the feature you want:\n- Any constraints or requirements (optional):", + }, +]; diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index 7b83b47324..c55fb03d42 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -1,4 +1,5 @@ import type { Task } from "@posthog/shared/domain-types"; +import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; @@ -47,6 +48,7 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { channelContext={instructions?.content} channelName={channelName} allowNoRepo + suggestions={CHANNEL_TASK_SUGGESTIONS} /> ); } diff --git a/packages/ui/src/features/navigation/taskBinderImpl.ts b/packages/ui/src/features/navigation/taskBinderImpl.ts index 3d08df40d8..a3ba46c5e1 100644 --- a/packages/ui/src/features/navigation/taskBinderImpl.ts +++ b/packages/ui/src/features/navigation/taskBinderImpl.ts @@ -47,6 +47,14 @@ export const navigationTaskBinder: NavigationTaskBinder = { const workspaces = await hostClient().workspace.getAll.query(); const existingWorkspace = workspaces?.[task.id] ?? null; + + // Repo-less channel task: its workspace is a synthetic scratch dir, not a + // registered folder. Never register it (that would pop the "initialize git" + // dialog on the empty scratch dir) or write a workspace row for it. + if (existingWorkspace?.isScratch) { + return undefined; + } + if (existingWorkspace?.folderId) { const folders = await hostClient().folders.getFolders.query(); const folder = folders.find((f) => f.id === existingWorkspace.folderId); diff --git a/packages/ui/src/features/task-detail/components/SuggestedPromptCard.tsx b/packages/ui/src/features/task-detail/components/SuggestedPromptCard.tsx new file mode 100644 index 0000000000..b1b36e9ab2 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/SuggestedPromptCard.tsx @@ -0,0 +1,66 @@ +import type { Icon } from "@phosphor-icons/react"; +import type { ExecutionMode } from "@posthog/shared/domain-types"; +import { Flex, Text } from "@radix-ui/themes"; + +export interface SuggestedPrompt { + label: string; + description: string; + prompt: string; + icon: Icon; + color: string; + /** Task mode to apply when this suggestion is selected. */ + mode: ExecutionMode; +} + +export interface SuggestedPromptCardProps { + suggestion: SuggestedPrompt; + onSelect: () => void; +} + +// A starter-prompt card for the channels new-task screen. Mirrors the look of +// SuggestedTaskCard (icon badge + title + description), but clicking it fills +// the composer instead of opening a detail dialog. +export function SuggestedPromptCard({ + suggestion, + onSelect, +}: SuggestedPromptCardProps) { + const PromptIcon = suggestion.icon; + + return ( + + ); +} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 5f997629c4..a508276afb 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -55,6 +55,10 @@ import { useInitialDirectoryFromFolderId } from "../hooks/useInitialDirectoryFro import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; +import { + type SuggestedPrompt, + SuggestedPromptCard, +} from "./SuggestedPromptCard"; import { SuggestedTasksPanel } from "./SuggestedTasksPanel"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; @@ -77,6 +81,12 @@ interface TaskInputProps { * needs a repo and attaches one lazily. */ allowNoRepo?: boolean; + /** + * Channels new-task starter prompts. When provided, a column of suggestion + * cards renders below the input while it's empty; clicking one fills the + * composer. Channels-only — omitted on the /code new-task screen. + */ + suggestions?: SuggestedPrompt[]; } export function TaskInput({ @@ -91,6 +101,7 @@ export function TaskInput({ channelContext, channelName, allowNoRepo, + suggestions, }: TaskInputProps = {}) { const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const trpc = useHostTRPC(); @@ -532,6 +543,7 @@ export function TaskInput({ setAdditionalDirectories, } = useTaskCreation({ editorRef, + sessionId, selectedDirectory, selectedRepository: selectedCloudRepository, githubUserIntegrationId: selectedGithubUserIntegrationId, @@ -674,7 +686,14 @@ export function TaskInput({
0 && editorIsEmpty + ? "38%" + : "50%", transform: "translate(-50%, -50%)", }} className="absolute left-1/2 z-[1] flex w-[calc(100%-2rem)] max-w-[600px] flex-col gap-2" @@ -922,7 +941,47 @@ export function TaskInput({ )}
- + {suggestions ? ( + suggestions.length > 0 && + editorIsEmpty && ( +
+ + Suggestions + +
+ {suggestions.map((suggestion) => ( + { + // Use pending content (not setContent) so the + // multi-line template — intro + "User input:" fill-in + // lines — keeps its line breaks; focuses at the end. + useDraftStore + .getState() + .actions.setPendingContent(sessionId, { + segments: [ + { type: "text", text: suggestion.prompt }, + ], + }); + // Bug/feature suggestions start in plan mode; the + // analysis ones start in auto mode. + if (isValidConfigValue(modeOption, suggestion.mode)) { + setConfigOption(modeOption.id, suggestion.mode); + } + }} + /> + ))} +
+
+ ) + ) : ( + + )}
diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 8e5f1aa48c..c990e71361 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -35,6 +35,7 @@ import { type EditorContent, extractFilePaths, } from "../../message-editor/content"; +import { useDraftStore } from "../../message-editor/draftStore"; import { useTaskInputHistoryStore } from "../../message-editor/taskInputHistoryStore"; import type { EditorHandle } from "../../message-editor/types"; import { useSettingsStore } from "../../settings/settingsStore"; @@ -47,6 +48,8 @@ const log = logger.scope("task-creation"); interface UseTaskCreationOptions { editorRef: React.RefObject; + /** Draft-store session id for the editor; cleared on successful creation. */ + sessionId: string; selectedDirectory: string; selectedRepository?: string | null; githubIntegrationId?: number; @@ -132,6 +135,7 @@ async function trackTaskCreated( export function useTaskCreation({ editorRef, + sessionId, selectedDirectory, selectedRepository, githubIntegrationId, @@ -290,15 +294,19 @@ export function useTaskCreation({ if (pendingTaskKey) { pendingTaskPromptStoreApi.move(pendingTaskKey, output.task.id); } + // Clear the draft BEFORE navigating away. When onTaskCreated + // navigates (e.g. channels), it can synchronously unmount/destroy + // the editor; clearing afterwards would throw in clearContent() + // before the persisted draft is wiped, leaving stale text behind. + if (!pendingTaskKey && !contentOverride) { + editor.clear(); + } if (onTaskCreated) { onTaskCreated(output.task); } else { void openTask(output.task); } useTourStore.getState().completeTour(createFirstTaskTour.id); - if (!pendingTaskKey && !contentOverride) { - editor.clear(); - } // Pre-flight already ran above for cloud; skip the service's duplicate check. }, { skipCloudUsagePreflight: true }, @@ -306,6 +314,14 @@ export function useTaskCreation({ if (result.success) { setAdditionalDirectoriesOverride(null); + // Guarantee the editor draft is wiped on success. editor.clear() + // above only runs inside the onTaskReady callback (and after it + // navigates the editor may be torn down); clearing the persisted + // draft directly here always runs and survives the unmount, so a + // submitted prompt never reappears on the next new task. + if (!contentOverride) { + useDraftStore.getState().actions.setDraft(sessionId, null); + } 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 @@ -355,6 +371,7 @@ export function useTaskCreation({ canSubmit, canSubmitBase, editorRef, + sessionId, selectedDirectory, selectedRepository, githubIntegrationId, diff --git a/packages/ui/src/router/routes/website/new.tsx b/packages/ui/src/router/routes/website/new.tsx index 1211854afe..0138523a67 100644 --- a/packages/ui/src/router/routes/website/new.tsx +++ b/packages/ui/src/router/routes/website/new.tsx @@ -1,3 +1,4 @@ +import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; import { useAppView } from "@posthog/ui/router/useAppView"; import { createFileRoute } from "@tanstack/react-router"; @@ -21,6 +22,7 @@ function WebsiteNewTaskRoute() { initialModel={view.initialModel} initialMode={view.initialMode} reportAssociation={view.reportAssociation} + suggestions={CHANNEL_TASK_SUGGESTIONS} /> ); } diff --git a/packages/workspace-server/src/services/agent/agent.test.ts b/packages/workspace-server/src/services/agent/agent.test.ts index 40c713a625..e5f711ca0a 100644 --- a/packages/workspace-server/src/services/agent/agent.test.ts +++ b/packages/workspace-server/src/services/agent/agent.test.ts @@ -176,6 +176,9 @@ function createMockDependencies() { workspaceSettings: { getWorktreeLocation: () => "/mock/worktrees", }, + foldersService: { + getFolders: vi.fn().mockResolvedValue([]), + }, loggerFactory: { scope: () => ({ info: vi.fn(), @@ -220,6 +223,7 @@ describe("AgentService", () => { deps.storagePaths as never, deps.workspaceRepository as never, deps.workspaceSettings as never, + deps.foldersService as never, deps.loggerFactory as never, ); vi.spyOn(service, "emit"); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 593981a00d..ebf83a9d3a 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -66,6 +66,9 @@ import { import { inject, injectable, preDestroy } from "inversify"; import { WORKSPACE_REPOSITORY } from "../../db/identifiers"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import type { FoldersService } from "../folders/folders"; +import { FOLDERS_SERVICE } from "../folders/identifiers"; +import type { RegisteredFolder } from "../folders/schemas"; import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers"; import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; @@ -372,6 +375,8 @@ export class AgentService extends TypedEventEmitter { private readonly workspaceRepository: IWorkspaceRepository, @inject(WORKSPACE_SETTINGS_SERVICE) private readonly workspaceSettings: IWorkspaceSettings, + @inject(FOLDERS_SERVICE) + private readonly foldersService: FoldersService, @inject(AGENT_LOGGER) loggerFactory: AgentLogger, ) { @@ -535,6 +540,7 @@ export class AgentService extends TypedEventEmitter { additionalDirectories?: string[], systemPromptOverride?: string, channelMode?: boolean, + knownLocalFolders?: RegisteredFolder[], ): { append: string; } { @@ -575,16 +581,30 @@ When creating pull requests, add the following footer at the end of the PR descr \`\`\``; if (channelMode) { + const localFolders = (knownLocalFolders ?? []).filter( + (f) => f.exists !== false, + ); + const localFoldersBlock = localFolders.length + ? `\n\nThe user already has these repositories checked out locally on this machine. Prefer reusing one of these over cloning anything:\n${localFolders + .map( + (f) => + ` - ${f.name} — ${f.path}${f.remoteUrl ? ` (${f.remoteUrl})` : ""}`, + ) + .join("\n")}` + : ""; + prompt += ` ## Channel task (no repository attached) You are running in a PostHog channel as a general-purpose assistant. This task may NOT need a code repository at all — it could be data analysis via PostHog tools, drafting a message, or answering a question. Do not assume you need a repo. - Your working directory is a scratch directory, not a git checkout. Treat it as empty. -- Decide from the user's request (and the channel CONTEXT.md included above, if any) whether the task actually requires working inside a code repository. -- Only if a repository is genuinely required: pick which one from the request and CONTEXT.md. Repositories named in CONTEXT.md are the most likely candidates — prefer them. Call \`list_repos\` to see what is available. -- Bring a repo into your workspace with the \`clone_repo\` tool (pass \`owner/repo\`). It clones into a subdirectory of your working directory and returns the path — cd into that path for all git work. -- If a repository is required but you cannot confidently determine which one, use the AskUserQuestion tool to ask the user to choose before cloning. Do not guess.`; +- Decide from the user's request (and the channel CONTEXT.md included above, if any) whether the task actually requires working inside a code repository. If it doesn't, just do the work in the scratch directory — do NOT attach a repo. + +If a repository IS genuinely required, attach one in this priority order: +1. **Reuse a folder the user already has locally.** ${localFolders.length ? "Pick the one that best matches the request and the channel CONTEXT.md, then `cd` into its absolute path and do all git and file work there. It is already on disk — do NOT clone it again." : "If the user names a folder or path, `cd` into that absolute path and work there."} +2. **If you can't confidently pick one** (none clearly match, or it's ambiguous), use the AskUserQuestion tool to ask the user which local folder to use, or for the path where the folder lives on this machine. Do not guess. +3. **Only as a last resort** — when the user has no local copy, or explicitly wants a fresh checkout — clone from remote. Call \`list_repos\` to see what's available (prefer repos named in CONTEXT.md), then **confirm with the user via AskUserQuestion before cloning**, and use \`clone_repo\` (pass \`owner/repo\`); it clones into a subdirectory of your working directory and returns the path to \`cd\` into.${localFoldersBlock}`; } if (customInstructions) { @@ -660,6 +680,13 @@ You are running in a PostHog channel as a general-purpose assistant. This task m this.workspaceSettings.getWorktreeLocation(), ); + // In channel mode the agent decides at runtime whether it needs a repo. Give + // it the user's previously-used local folders so it can reuse one (or ask) + // instead of cloning from remote. Only fetched for channel sessions. + const knownLocalFolders = channelMode + ? await this.foldersService.getFolders().catch(() => []) + : []; + const additionalDirectories = taskId === "__preview__" ? [] @@ -717,6 +744,7 @@ You are running in a PostHog channel as a general-purpose assistant. This task m additionalDirectories, systemPromptOverride, channelMode, + knownLocalFolders, ); const bundledSkillsDir = join( diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index c21277fec8..df3d844c58 100644 --- a/packages/workspace-server/src/services/workspace/workspace.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -898,6 +898,7 @@ export class WorkspaceService extends TypedEventEmitter baseBranch: null, linkedBranch: null, createdAt: new Date().toISOString(), + isScratch: true, }; } diff --git a/packages/workspace-server/src/services/workspace/workspace.verify.test.ts b/packages/workspace-server/src/services/workspace/workspace.verify.test.ts index 0a086feb41..e287f2ad31 100644 --- a/packages/workspace-server/src/services/workspace/workspace.verify.test.ts +++ b/packages/workspace-server/src/services/workspace/workspace.verify.test.ts @@ -166,10 +166,14 @@ describe("WorkspaceService.verifyWorkspaceExists", () => { mode: "local", folderPath: scratchPath, worktreePath: null, + // Marked so the navigation task binder skips folder registration (and the + // "initialize git" dialog) for repo-less channel tasks. + isScratch: true, }); const all = await service.getAllWorkspaces(); expect(all[TASK_ID]?.folderPath).toBe(scratchPath); + expect(all[TASK_ID]?.isScratch).toBe(true); // It is not backed by a DB row. expect(workspaceRepo.findByTaskId(TASK_ID)).toBeNull();