diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e5762e38a..49c7d5f5b 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1001,6 +1001,56 @@ export class PostHogAPIClient { return all; } + // The task currently generating this folder's CONTEXT.md, shared across the + // project so any user sees an in-progress generation (instead of fragile + // local state). Keyed on the folder row (which always exists), not the + // instructions object (which doesn't until the first version is published). + // Returns null when nothing is generating — or, until the backend ships this + // endpoint, on 404 (the feature degrades to no shared indicator). + async getDesktopFolderGenerationTask( + folderId: string, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/context_generation/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error( + `Failed to fetch folder generation task: ${response.statusText}`, + ); + } + const data = (await response.json()) as { task_id?: string | null }; + return data.task_id ?? null; + } + + // Record (or clear, with null) the task generating this folder's CONTEXT.md. + async setDesktopFolderGenerationTask( + folderId: string, + taskId: string | null, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/context_generation/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "put", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ task_id: taskId }), + }, + }); + if (!response.ok && response.status !== 404) { + throw new Error( + `Failed to set folder generation task: ${response.statusText}`, + ); + } + } + async getGithubLogin(): Promise { const data = (await this.api.get("/api/users/{uuid}/github_login/", { path: { uuid: "@me" }, diff --git a/packages/core/src/editor/prompt-builder.test.ts b/packages/core/src/editor/prompt-builder.test.ts new file mode 100644 index 000000000..2fc589ef4 --- /dev/null +++ b/packages/core/src/editor/prompt-builder.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + buildChannelContextBlock, + buildChannelContextText, +} from "./prompt-builder"; + +describe("buildChannelContextText", () => { + it.each([[undefined], [" \n "]] as const)( + "returns null for empty or whitespace content (%s)", + (input) => { + expect(buildChannelContextText(input)).toBeNull(); + }, + ); + + it("wraps the trimmed body, optionally with an escaped channel name", () => { + expect( + buildChannelContextText("body")?.startsWith(""), + ).toBe(true); + expect(buildChannelContextText("body", 'a"b')).toContain( + 'channel="a"b"', + ); + }); + + it("backs the ContentBlock form", () => { + const text = buildChannelContextText("# Billing", "billing"); + const block = buildChannelContextBlock("# Billing", "billing"); + expect(block).toEqual({ type: "text", text }); + }); +}); + +describe("buildChannelContextBlock", () => { + it.each([[undefined], [null], [""], [" \n "]] as const)( + "returns null for empty or whitespace content (%s)", + (input) => { + expect(buildChannelContextBlock(input)).toBeNull(); + }, + ); + + it("wraps trimmed content in a labeled, non-binding background block", () => { + const block = buildChannelContextBlock(" # Billing\n\nUse cents. "); + expect(block).not.toBeNull(); + expect(block?.type).toBe("text"); + const text = (block as { text: string }).text; + // Framed as optional reference, not instructions. + expect(text).toContain("reference material, not instructions"); + expect(text).toContain("don't limit your work to it"); + // The element wraps the framing + trimmed body so the UI can collapse it. + expect(text.startsWith("\n")).toBe(true); + expect(text.endsWith("\n# Billing\n\nUse cents.\n")).toBe( + true, + ); + }); + + it("embeds the channel name as an escaped attribute when provided", () => { + const block = buildChannelContextBlock("body", 'on"b'); + const text = (block as { text: string }).text; + expect(text.startsWith('')).toBe(true); + }); +}); diff --git a/packages/core/src/editor/prompt-builder.ts b/packages/core/src/editor/prompt-builder.ts index cdcfa0f00..8e966b910 100644 --- a/packages/core/src/editor/prompt-builder.ts +++ b/packages/core/src/editor/prompt-builder.ts @@ -1,5 +1,5 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { isAbsolutePath, pathToFileUri } from "@posthog/shared"; +import { escapeXmlAttr, isAbsolutePath, pathToFileUri } from "@posthog/shared"; export async function buildPromptBlocks( textContent: string, @@ -25,3 +25,34 @@ export async function buildPromptBlocks( return blocks; } + +// Wraps a channel's CONTEXT.md as supplementary prompt text. Framed as optional +// background so the agent treats it as a helpful starting point — it may use +// what's relevant and ignore the rest, and must not limit its work to it. The +// whole thing is wrapped in a `` element +// (carrying the channel name) so the conversation UI can collapse it into a +// single tag instead of dumping the full body inline. Returns null for empty/ +// whitespace content so callers can skip injection. +// +// Returns the raw string so it can be folded into either a ContentBlock (local +// tasks, via buildChannelContextBlock) or a plain message string (cloud tasks, +// whose initial message is sent as text). +export function buildChannelContextText( + content: string | undefined | null, + channelName?: string | null, +): string | null { + const trimmed = content?.trim(); + if (!trimmed) return null; + const name = channelName?.trim(); + const nameAttr = name ? ` channel="${escapeXmlAttr(name)}"` : ""; + return `\nThe workspace this task was created in has a saved CONTEXT.md with background that's often relevant to tasks here. Treat it as reference material, not instructions: draw on what's helpful, ignore what isn't, and don't limit your work to it.\n\n${trimmed}\n`; +} + +// ContentBlock form of {@link buildChannelContextText}, for local task prompts. +export function buildChannelContextBlock( + content: string | undefined | null, + channelName?: string | null, +): ContentBlock | null { + const text = buildChannelContextText(content, channelName); + return text ? { type: "text", text } : null; +} diff --git a/packages/core/src/panels/panelLayoutTransforms.ts b/packages/core/src/panels/panelLayoutTransforms.ts index 97ddf8b7c..ded72feaa 100644 --- a/packages/core/src/panels/panelLayoutTransforms.ts +++ b/packages/core/src/panels/panelLayoutTransforms.ts @@ -216,6 +216,99 @@ export function openTabInSplit( return { panelTree: finalTree, focusedPanelId: newPanelId, ...metadata }; } +// Opens a channel-context snapshot as a tab in the right-side split (creating +// the split if needed), mirroring openTabInSplit but carrying the content +// inline in the tab's data instead of deriving it from the tab id. Re-opening +// the same tab id just activates the existing tab. +export function openContextInSplit( + layout: TaskLayout, + tabId: string, + label: string, + context: { channelName: string | null; body: string }, +): Partial { + const buildTab = (): Tab => ({ + id: tabId, + label, + data: { + type: "context", + channelName: context.channelName, + body: context.body, + }, + component: null, + draggable: true, + closeable: true, + }); + + const existingTab = findTabInTree(layout.panelTree, tabId); + if (existingTab) { + const updatedTree = updateTreeNode( + layout.panelTree, + existingTab.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { ...panel.content, activeTabId: tabId }, + }; + }, + ); + return { panelTree: updatedTree, focusedPanelId: existingTab.panelId }; + } + + const nonMainPanel = findNonMainLeafPanel(layout.panelTree); + if (nonMainPanel) { + const updatedTree = updateTreeNode( + layout.panelTree, + nonMainPanel.id, + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: [...panel.content.tabs, buildTab()], + activeTabId: tabId, + }, + }; + }, + ); + return { panelTree: updatedTree, focusedPanelId: nonMainPanel.id }; + } + + const mainPanel = getLeafPanel( + layout.panelTree, + DEFAULT_PANEL_IDS.MAIN_PANEL, + ); + if (!mainPanel) return {}; + + const newPanelId = generatePanelId(); + const newPanel: PanelNode = { + type: "leaf", + id: newPanelId, + content: { + id: newPanelId, + tabs: [buildTab()], + activeTabId: tabId, + showTabs: true, + droppable: true, + }, + }; + + const splitTree = updateTreeNode( + layout.panelTree, + DEFAULT_PANEL_IDS.MAIN_PANEL, + (panel) => ({ + type: "group" as const, + id: generatePanelId(), + direction: "horizontal" as const, + sizes: [50, 50], + children: [panel, newPanel], + }), + ); + + return { panelTree: splitTree, focusedPanelId: newPanelId }; +} + export function addRecentFile( recentFiles: string[] | undefined, filePath: string, diff --git a/packages/core/src/panels/panelTypes.ts b/packages/core/src/panels/panelTypes.ts index 88e9f2798..265ab8729 100644 --- a/packages/core/src/panels/panelTypes.ts +++ b/packages/core/src/panels/panelTypes.ts @@ -27,6 +27,13 @@ export type TabData = | { type: "review"; } + | { + // A read-only snapshot of a channel's CONTEXT.md, shown exactly as it was + // sent with the task's prompt (carried inline, not fetched from disk). + type: "context"; + channelName: string | null; + body: string; + } | { type: "other"; }; diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 42a532292..737092165 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -1,4 +1,8 @@ -import { buildPromptBlocks } from "@posthog/core/editor/prompt-builder"; +import { + buildChannelContextBlock, + buildChannelContextText, + buildPromptBlocks, +} from "@posthog/core/editor/prompt-builder"; import type { ConnectParams, SessionService, @@ -235,6 +239,20 @@ export class TaskCreationSaga extends Saga< input.filePaths, ) : null; + + // The local connect path appends channel CONTEXT.md to initialPrompt; + // cloud sends its first message as text, so fold the same block into + // pendingUserMessage here. The conversation UI parses it identically. + const channelContextText = buildChannelContextText( + input.channelContext, + input.channelName, + ); + const messageText = transport?.messageText; + const pendingUserMessage = channelContextText + ? messageText + ? `${messageText}\n\n${channelContextText}` + : channelContextText + : messageText; const taskRun = await this.deps.posthogClient.createTaskRun(task.id, { environment: "cloud", mode: "interactive", @@ -268,7 +286,7 @@ export class TaskCreationSaga extends Saga< task.id, taskRun.id, { - pendingUserMessage: transport?.messageText, + pendingUserMessage, pendingUserArtifactIds: pendingUserArtifactIds.length > 0 ? pendingUserArtifactIds @@ -316,6 +334,17 @@ export class TaskCreationSaga extends Saga< ) : undefined; + // Append the channel's CONTEXT.md as optional background, so tasks made + // in a channel start with the shared context the agent would otherwise + // have to rediscover. Kept after the user's prompt so the request leads. + const channelContextBlock = buildChannelContextBlock( + input.channelContext, + input.channelName, + ); + if (initialPrompt && channelContextBlock) { + initialPrompt.push(channelContextBlock); + } + await this.step({ name: "agent_session", execute: async () => { diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 310b4e244..92f7005f3 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -17,6 +17,8 @@ export interface PrepareTaskInputOptions { sandboxEnvironmentId?: string; signalReportId?: string; additionalDirectories?: string[]; + channelContext?: string; + channelName?: string; } export function prepareTaskInput( @@ -49,6 +51,8 @@ export function prepareTaskInput( options.signalReportId && isCloud ? "signal_report" : undefined, signalReportId: options.signalReportId, additionalDirectories: isCloud ? undefined : options.additionalDirectories, + channelContext: options.channelContext, + channelName: options.channelName, }; } diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 7c28b4643..53d0e1573 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -32,6 +32,14 @@ export interface TaskCreationInput { cloudRunSource?: CloudRunSource; signalReportId?: string; additionalDirectories?: string[]; + /** + * CONTEXT.md of the channel a task was created in, if any. Appended to the + * agent's initial prompt as optional background — reference material the + * agent may draw on, not instructions it must follow. + */ + channelContext?: string; + /** Display name of that channel, embedded in the context block for the UI. */ + channelName?: string; } export interface TaskCreationOutput { diff --git a/packages/ui/src/features/canvas/components/WebsiteContext.tsx b/packages/ui/src/features/canvas/components/WebsiteContext.tsx index f9cef94f2..1836ded2a 100644 --- a/packages/ui/src/features/canvas/components/WebsiteContext.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteContext.tsx @@ -1,12 +1,32 @@ -import { FileTextIcon, HashIcon } from "@phosphor-icons/react"; +import { + FileTextIcon, + HashIcon, + SparkleIcon, + SpinnerGapIcon, +} from "@phosphor-icons/react"; import { FolderInstructionsConflictError } from "@posthog/api-client/posthog-client"; +import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { + useFolderGenerationTask, + useFolderGenerationTaskMutation, +} from "@posthog/ui/features/canvas/hooks/useFolderGenerationTask"; import { useFolderInstructions, useFolderInstructionsMutations, useFolderInstructionsVersions, } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { + type GenerateContextTarget, + useGenerateContext, +} from "@posthog/ui/features/canvas/hooks/useGenerateContext"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { FolderPicker } from "@posthog/ui/features/folder-picker/FolderPicker"; +import { GitHubRepoPicker } from "@posthog/ui/features/folder-picker/GitHubRepoPicker"; +import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { useSessionForTask } from "@posthog/ui/features/sessions/useSession"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Box, @@ -20,6 +40,8 @@ import { Text, TextArea, } from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { useEffect, useMemo, useState } from "react"; type Mode = "rendered" | "edit"; @@ -46,6 +68,7 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { isLoading: isLoadingLatest, isFetching: isFetchingLatest, error: latestError, + refetch: refetchLatest, } = useFolderInstructions(channelId); const { data: versions = [], isLoading: isLoadingVersions } = @@ -58,6 +81,68 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { const [draft, setDraft] = useState(""); const [hasDraft, setHasDraft] = useState(false); + const hasInstructions = (latest?.content ?? "").trim().length > 0; + + // CONTEXT.md generation runs as a normal task (local or cloud) in the + // channel's repo. The "which task" association is stored server-side (shared + // across the project) so any user sees an in-progress generation. We poll it + // and the file while there's no published content yet. + const pollGen = !hasInstructions; + const { data: genTaskId } = useFolderGenerationTask(channelId, { + refetchInterval: pollGen ? 5000 : false, + }); + const { set: setGenerationTask } = useFolderGenerationTaskMutation(channelId); + + const genTaskQuery = useQuery({ + ...taskDetailQuery(genTaskId ?? ""), + enabled: !!genTaskId && pollGen, + refetchInterval: genTaskId && pollGen ? 5000 : false, + }); + const genTask = genTaskQuery.data; + const genSession = useSessionForTask(genTaskId ?? undefined); + + // Running is environment-aware: cloud runs report status via cloudStatus / + // latest_run.status (a cloud session stays "connected" while polling), while + // local runs are tied to the live ACP session. While the task record is still + // loading we assume running to avoid a flash of "stopped". + const running = (() => { + if (!genTaskId) return false; + if (genTaskQuery.isLoading) return true; + if (genTask?.latest_run?.environment === "cloud") { + const cloudStatus = + genSession?.cloudStatus ?? genTask?.latest_run?.status ?? null; + return !isTerminalStatus(cloudStatus); + } + return ( + genSession?.status === "connecting" || genSession?.status === "connected" + ); + })(); + const isGenerating = !!genTaskId && pollGen && running; + const isStopped = !!genTaskId && pollGen && !running; + + // While the agent runs, poll the published file so it shows up without a + // manual refresh once the agent publishes via the MCP. + useEffect(() => { + if (!isGenerating) return; + const id = setInterval(() => void refetchLatest(), 5000); + return () => clearInterval(id); + }, [isGenerating, refetchLatest]); + + // The agent publishes mid-run, just before its run ends — so when the run + // stops, refetch once to catch a just-published file before concluding it + // stopped without producing one. + useEffect(() => { + if (isStopped) void refetchLatest(); + }, [isStopped, refetchLatest]); + + // Once the file exists, the generation task has served its purpose — clear the + // server association so everyone stops tracking it. (The backend should also + // auto-clear on publish; this covers clients that observe content first.) + useEffect(() => { + if (genTaskId && hasInstructions) + void setGenerationTask(null).catch(() => {}); + }, [genTaskId, hasInstructions, setGenerationTask]); + // Seed the editor draft from the latest content the first time we land on // edit mode (or whenever latest changes while we're not actively editing). // We don't blow away an in-flight edit just because the cache refetched. @@ -145,7 +230,6 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { // empty state — otherwise MarkdownRenderer paints an invisible empty block // and the page looks blank. const renderedContent = latest?.content ?? ""; - const hasInstructions = renderedContent.trim().length > 0; return ( @@ -261,7 +345,9 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { className="scroll-area-constrain-width min-h-0 flex-1" > - {selectedVersion ? ( + {isGenerating && genTaskId ? ( + + ) : selectedVersion ? ( Viewing v{selectedVersion.version} metadata. Past content is not @@ -276,7 +362,9 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { ) : ( { setDraft(EMPTY_TEMPLATE); setHasDraft(true); @@ -306,10 +394,15 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { } function EmptyState({ + channelId, channelName, + stoppedTaskId, onCreate, }: { + channelId: string; channelName: string; + /** A prior generation task that stopped without producing a file, if any. */ + stoppedTaskId: string | null; onCreate: () => void; }) { return ( @@ -332,13 +425,207 @@ function EmptyState({ files, and anything else that isn't obvious from the code. - + + + + ); +} + +type GenMode = "local" | "cloud"; + +// Lets the user pick a local repo or a connected GitHub repo (cloud), then kicks +// off a normal task that explores the code + PostHog data and publishes +// CONTEXT.md via the MCP. Reuses the same pickers as the task input bar. +function GenerateWithAgent({ + channelId, + channelName, + regenerate, +}: { + channelId: string; + channelName: string; + regenerate: boolean; +}) { + const { generate, isStarting } = useGenerateContext(channelId, channelName); + const lastUsedRunMode = useSettingsStore((s) => s.lastUsedRunMode); + + const [picking, setPicking] = useState(false); + const [genMode, setGenMode] = useState( + lastUsedRunMode === "cloud" ? "cloud" : "local", + ); + + if (!picking) { + return ( + + ); + } + + return ( + + setGenMode(v as GenMode)} + > + Local + Cloud + + {genMode === "local" ? ( + + ) : ( + + )} ); } +interface GenerateSubProps { + generate: (target: GenerateContextTarget) => Promise; + isStarting: boolean; +} + +function GenerateLocal({ generate, isStarting }: GenerateSubProps) { + const [repoPath, setRepoPath] = useState(""); + return ( + + + + + ); +} + +function GenerateCloud({ generate, isStarting }: GenerateSubProps) { + const { + repositories, + getUserIntegrationIdForRepo, + isLoadingRepos, + hasGithubIntegration, + } = useUserRepositoryIntegration(); + const lastUsedCloudRepository = useSettingsStore( + (s) => s.lastUsedCloudRepository, + ); + const [repo, setRepo] = useState( + lastUsedCloudRepository ?? null, + ); + const integrationId = repo ? getUserIntegrationIdForRepo(repo) : undefined; + + if (!hasGithubIntegration && !isLoadingRepos) { + return ( + + Connect GitHub to generate in the cloud. + + ); + } + + return ( + + + + + ); +} + +// Shown while the generation task is running: a centered status with a spinner +// and a button to jump to the task that's doing the work. +function GeneratingState({ + channelId, + taskId, +}: { + channelId: string; + taskId: string; +}) { + return ( + + + + + + Generating + + An agent is writing this CONTEXT.md. + + + + + ); +} + +// A compact, readable handle for a task uuid in inline text. +function shortTaskId(taskId: string): string { + return taskId.slice(0, 8); +} + // `created_at` is an ISO timestamp; we render it as a short local string for // the version dropdown. Falls back to the raw string if Date parsing fails. function formatTimestamp(iso: string): string { diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index b881d4f97..eb84ea030 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -1,5 +1,7 @@ import type { Task } from "@posthog/shared/domain-types"; +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"; import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { toast } from "@posthog/ui/primitives/toast"; @@ -15,6 +17,11 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const { fileTask } = useChannelTaskMutations(); + const { channels } = useChannels(); + const channelName = channels.find((c) => c.id === channelId)?.name; + // The channel's CONTEXT.md, passed to the agent as optional background so + // tasks created here start with the shared context. Absent/empty is fine. + const { data: instructions } = useFolderInstructions(channelId); const onTaskCreated = useCallback( (task: Task) => { @@ -34,5 +41,11 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { [channelId, fileTask, navigate, queryClient], ); - return ; + return ( + + ); } diff --git a/packages/ui/src/features/canvas/contextPrompt.ts b/packages/ui/src/features/canvas/contextPrompt.ts new file mode 100644 index 000000000..6597a6df8 --- /dev/null +++ b/packages/ui/src/features/canvas/contextPrompt.ts @@ -0,0 +1,42 @@ +// Builds the prompt for the task that generates a channel's CONTEXT.md. The +// task runs as a normal agent task in the channel's repo, so the agent has full +// tools; this is the task's content (its first user message). CONTEXT.md is not +// a file on disk — it lives in PostHog — so the agent must publish the result +// via the PostHog MCP rather than writing a file. +export function buildContextGenerationPrompt(input: { + channelName: string; + channelId: string; +}): string { + const { channelName, channelId } = input; + return `Generate a CONTEXT.md for the channel/folder "${channelName}". + +CONTEXT.md tells future agents the specific, non-obvious details they need to +work in "${channelName}": what it is, key files, conventions, gotchas, and the +PostHog resources that relate to it. + +Investigate two sources: +1. This repository — use Read, Grep, and Glob to find code, directories, and + config related to "${channelName}" (conventions, key files, gotchas). +2. PostHog — use the PostHog MCP to find data related to "${channelName}" in + this project: feature flags, experiments, surveys, notebooks, insights, web + analytics, and persons. Operate only on this project. + +When you have gathered enough, PUBLISH the document by calling the PostHog MCP +tool \`desktop-file-system-instructions-partial-update\` exactly once with: +- id: "${channelId}" +- content: the full CONTEXT.md markdown +- base_version: the current instructions version, or 0 if none exists yet + +Structure the markdown with these sections: +1. Overview — what "${channelName}" is and why it exists. +2. Key files — the most important paths, each with a one-line purpose. +3. Conventions & gotchas — non-obvious rules, patterns, and pitfalls. +4. Related PostHog resources — relevant flags/experiments/surveys/notebooks/ + insights with links. + +Write the document in terse, high-signal language: drop articles and filler, +prefer fragments and short phrases over full sentences, cut anything that does +not carry technical substance. Keep it concise. CONTEXT.md lives in PostHog, not +on disk, so publishing via the MCP tool is what saves it — do not just write a +local file.`; +} diff --git a/packages/ui/src/features/canvas/hooks/useFolderGenerationTask.ts b/packages/ui/src/features/canvas/hooks/useFolderGenerationTask.ts new file mode 100644 index 000000000..87bab52e1 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useFolderGenerationTask.ts @@ -0,0 +1,60 @@ +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +// The task generating a channel's CONTEXT.md is stored server-side (keyed on the +// folder), so it's shared across everyone in the project — any user sees an +// in-progress generation and won't double-start it. Mirrors the +// useFolderInstructions query/mutation shape. + +const GENERATION_TASK_QUERY_KEY = (folderId: string) => + ["folder-generation-task", folderId] as const; + +export function useFolderGenerationTask( + folderId: string | null, + options?: { enabled?: boolean; refetchInterval?: number | false }, +) { + return useAuthenticatedQuery( + folderId + ? GENERATION_TASK_QUERY_KEY(folderId) + : (["folder-generation-task", "none"] as const), + async (client) => { + if (!folderId) return null; + return client.getDesktopFolderGenerationTask(folderId); + }, + { + enabled: Boolean(folderId) && (options?.enabled ?? true), + staleTime: 0, + refetchInterval: options?.refetchInterval ?? false, + }, + ); +} + +// set(taskId) records the generating task; set(null) clears it. Best-effort: +// callers should not block generation on this (the backend endpoint may not +// exist yet, in which case the client no-ops on 404). +export function useFolderGenerationTaskMutation(folderId: string | null) { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async (taskId: string | null) => { + if (!client || !folderId) return; + await client.setDesktopFolderGenerationTask(folderId, taskId); + }, + onSuccess: () => { + if (!folderId) return; + void queryClient.invalidateQueries({ + queryKey: GENERATION_TASK_QUERY_KEY(folderId), + }); + }, + }); + + const set = useCallback( + (taskId: string | null) => mutation.mutateAsync(taskId), + [mutation], + ); + + return { set }; +} diff --git a/packages/ui/src/features/canvas/hooks/useGenerateContext.ts b/packages/ui/src/features/canvas/hooks/useGenerateContext.ts new file mode 100644 index 000000000..2733df555 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useGenerateContext.ts @@ -0,0 +1,88 @@ +import { + TASK_SERVICE, + type TaskService, +} from "@posthog/core/task-detail/taskService"; +import { useService } from "@posthog/di/react"; +import { buildContextGenerationPrompt } from "@posthog/ui/features/canvas/contextPrompt"; +import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; +import { useFolderGenerationTaskMutation } from "@posthog/ui/features/canvas/hooks/useFolderGenerationTask"; +import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; +import { toast } from "@posthog/ui/primitives/toast"; +import { useCallback, useState } from "react"; + +// Where the generation task runs: a local clone or a connected GitHub repo +// (cloud sandbox). Cloud needs the user-integration id; both omit the branch +// (defaults). +export type GenerateContextTarget = + | { mode: "local"; repoPath: string } + | { + mode: "cloud"; + repository: string; + githubUserIntegrationId: string; + branch?: string | null; + }; + +// Kicks off CONTEXT.md generation as a normal task (local or cloud) in the +// channel's repo. The task is filed to the channel and recorded server-side as +// the channel's generation task so every user's CONTEXT.md view can track it. +export function useGenerateContext(channelId: string, channelName: string) { + const taskService = useService(TASK_SERVICE); + const { invalidateTasks } = useCreateTask(); + const { fileTask } = useChannelTaskMutations(); + const { set: setGenerationTask } = useFolderGenerationTaskMutation(channelId); + const [isStarting, setIsStarting] = useState(false); + + const generate = useCallback( + async (target: GenerateContextTarget): Promise => { + setIsStarting(true); + try { + const base = { + content: buildContextGenerationPrompt({ channelName, channelId }), + taskDescription: `Generate CONTEXT.md for #${channelName}`, + }; + const result = await taskService.createTask( + target.mode === "cloud" + ? { + ...base, + repository: target.repository, + githubUserIntegrationId: target.githubUserIntegrationId, + workspaceMode: "cloud", + branch: target.branch ?? null, + } + : { + ...base, + repoPath: target.repoPath, + workspaceMode: "local", + }, + (output) => invalidateTasks(output.task), + ); + + if (!result.success) { + toast.error("Couldn't start CONTEXT.md generation", { + description: result.error, + }); + return null; + } + + const task = result.data.task; + // File into the channel + record as the (shared) generation task. Both + // are best-effort: a failure here shouldn't undo a started task. + void fileTask(channelId, task.id, task.title).catch(() => {}); + void setGenerationTask(task.id).catch(() => {}); + return task.id; + } finally { + setIsStarting(false); + } + }, + [ + taskService, + invalidateTasks, + fileTask, + setGenerationTask, + channelId, + channelName, + ], + ); + + return { generate, isStarting }; +} diff --git a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx index b29dcb9e5..e2929b8b8 100644 --- a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx @@ -1,4 +1,4 @@ -import { ChatCenteredText, Terminal } from "@phosphor-icons/react"; +import { ChatCenteredText, FileText, Terminal } from "@phosphor-icons/react"; import { resolveTabAbsolutePath } from "@posthog/core/panels/resolveTabPath"; import type { Task } from "@posthog/shared/domain-types"; import { useCallback, useEffect, useMemo, useRef } from "react"; @@ -107,6 +107,8 @@ export function useTabInjection( icon = ; } else if (tab.data.type === "action") { icon = ; + } else if (tab.data.type === "context") { + icon = ; } } diff --git a/packages/ui/src/features/panels/panelLayoutStore.ts b/packages/ui/src/features/panels/panelLayoutStore.ts index 55a1a4517..9a5dc00d6 100644 --- a/packages/ui/src/features/panels/panelLayoutStore.ts +++ b/packages/ui/src/features/panels/panelLayoutStore.ts @@ -7,6 +7,7 @@ import { closeTabsToRight as coreCloseTabsToRight, keepTab as coreKeepTab, moveTab as coreMoveTab, + openContextInSplit as coreOpenContextInSplit, openTab as coreOpenTab, openTabInSplit as coreOpenTabInSplit, reorderTabs as coreReorderTabs, @@ -50,6 +51,10 @@ export interface PanelLayoutStore { filePath: string, asPreview?: boolean, ) => void; + openChannelContextInSplit: ( + taskId: string, + context: { channelName: string | null; body: string }, + ) => void; keepTab: (taskId: string, panelId: string, tabId: string) => void; closeTab: (taskId: string, panelId: string, tabId: string) => void; closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void; @@ -160,6 +165,24 @@ export const usePanelLayoutStore = createWithEqualityFn()( }); }, + openChannelContextInSplit: (taskId, context) => { + const tabId = `context-${context.channelName ?? "channel"}`; + const label = `${context.channelName ? `#${context.channelName} ` : ""}CONTEXT.md`; + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreOpenContextInSplit( + layout, + tabId, + label, + context, + ) as Partial, + ), + ); + }, + keepTab: (taskId, panelId, tabId) => { set((state) => updateTaskLayout( diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 9e9f56797..5b1e618cc 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -207,6 +207,7 @@ export function ConversationView({ attachments={item.attachments} timestamp={item.timestamp} animate={!initialItemIds.has(item.id)} + taskId={taskId} sourceUrl={ slackThreadUrl && item.id === firstUserMessageId ? slackThreadUrl diff --git a/packages/ui/src/features/sessions/components/session-update/UserMessage.test.tsx b/packages/ui/src/features/sessions/components/session-update/UserMessage.test.tsx index a1ac9cd60..6f1daa2bf 100644 --- a/packages/ui/src/features/sessions/components/session-update/UserMessage.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.test.tsx @@ -1,24 +1,78 @@ +import { ServiceProvider } from "@posthog/di/react"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { Container } from "inversify"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + FEATURE_FLAGS, + type FeatureFlags, +} from "../../../feature-flags/identifiers"; import { UserMessage } from "./UserMessage"; +function renderWithFlags(node: ReactNode, bluebirdEnabled: boolean) { + const flags: FeatureFlags = { + isEnabled: () => bluebirdEnabled, + onFlagsLoaded: () => () => {}, + }; + const container = new Container(); + container.bind(FEATURE_FLAGS).toConstantValue(flags); + return render( + + {node} + , + ); +} + +const PROMPT_WITH_CONTEXT = + 'do the thing\n\n# Billing\n'; + describe("UserMessage", () => { + // useFeatureFlag falls back to import.meta.env.DEV, which is true under + // vitest. Pin DEV off in the flag-gating cases so they exercise the flag + // itself, not the dev default. + afterEach(() => { + vi.unstubAllEnvs(); + }); it("renders attachment chips for cloud prompts", () => { - render( - - - , + renderWithFlags( + , + true, ); expect(screen.getByText("read this file")).toBeInTheDocument(); expect(screen.getByText("test.txt")).toBeInTheDocument(); expect(screen.getByText("notes.md")).toBeInTheDocument(); }); + + it("shows the channel CONTEXT.md tag when project-bluebird is enabled", () => { + vi.stubEnv("DEV", false); + renderWithFlags( + , + true, + ); + + expect(screen.getByText("do the thing")).toBeInTheDocument(); + expect(screen.getByText("#billing CONTEXT.md")).toBeInTheDocument(); + }); + + it("hides the tag but still strips the block when project-bluebird is off", () => { + vi.stubEnv("DEV", false); + renderWithFlags( + , + false, + ); + + // Prompt still renders, the channel-context tag does not. + expect(screen.getByText("do the thing")).toBeInTheDocument(); + expect(screen.queryByText("#billing CONTEXT.md")).not.toBeInTheDocument(); + // The raw XML must never leak to flag-off viewers. + expect(screen.queryByText(/channel_context/)).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx index 9304038ed..db0b50db4 100644 --- a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx @@ -4,14 +4,19 @@ import { Check, Copy, File, + FileText, SlackLogo, } from "@phosphor-icons/react"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { Box, Flex, IconButton } from "@radix-ui/themes"; import { motion } from "framer-motion"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Tooltip } from "../../../../primitives/Tooltip"; import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; +import { useFeatureFlag } from "../../../feature-flags/useFeatureFlag"; +import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; import type { UserMessageAttachment } from "../../userMessageTypes"; +import { extractChannelContext } from "./channelContext"; import { hasFileMentions, MentionChip, @@ -26,6 +31,8 @@ interface UserMessageProps { sourceUrl?: string; attachments?: UserMessageAttachment[]; animate?: boolean; + /** Task the message belongs to — needed to open the context file tab. */ + taskId?: string; } function formatTimestamp(ts: number): string { @@ -50,8 +57,28 @@ export const UserMessage = memo(function UserMessage({ sourceUrl, attachments = [], animate = true, + taskId, }: UserMessageProps) { - const containsFileMentions = hasFileMentions(content); + // A channel's CONTEXT.md, if injected into this prompt, is collapsed into a + // clickable tag instead of rendered inline; the rest of the prompt renders + // normally. Clicking the tag opens the snapshot as a file tab. The clickable + // tag + split tab is a project-bluebird feature, but we always strip the block + // so the raw XML never leaks for flag-off viewers. + const bluebirdEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const channelContext = useMemo( + () => extractChannelContext(content), + [content], + ); + const displayContent = channelContext ? channelContext.stripped : content; + const showChannelContextTag = !!channelContext && bluebirdEnabled; + const openChannelContextInSplit = usePanelLayoutStore( + (s) => s.openChannelContextInSplit, + ); + + const containsFileMentions = hasFileMentions(displayContent); const showAttachmentChips = attachments.length > 0 && !containsFileMentions; const [copied, setCopied] = useState(false); const [isExpanded, setIsExpanded] = useState(false); @@ -72,11 +99,11 @@ export const UserMessage = memo(function UserMessage({ }, []); const handleCopy = useCallback(() => { - navigator.clipboard.writeText(content); + navigator.clipboard.writeText(displayContent); setCopied(true); clearTimeout(copiedTimerRef.current); copiedTimerRef.current = setTimeout(() => setCopied(false), 2000); - }, [content]); + }, [displayContent]); return ( {containsFileMentions ? ( - parseFileMentions(content) + parseFileMentions(displayContent) ) : ( - + + )} + {showChannelContextTag && channelContext && ( + + } + label={`${ + channelContext.mention.name + ? `#${channelContext.mention.name} ` + : "" + }CONTEXT.md`} + onClick={ + taskId + ? () => + openChannelContextInSplit(taskId, { + channelName: channelContext.mention.name, + body: channelContext.mention.body, + }) + : undefined + } + /> + )} {showAttachmentChips && ( { + it("returns null when there is no channel-context element", () => { + expect(extractChannelContext("just a normal prompt")).toBeNull(); + expect(hasChannelContext("just a normal prompt")).toBe(false); + }); + + it("extracts the channel name, body, and strips the element from the text", () => { + const content = + 'Fix the bug.\nbackground here\n'; + const result = extractChannelContext(content); + expect(result).not.toBeNull(); + expect(result?.mention.name).toBe("onboarding"); + expect(result?.mention.body).toBe("background here"); + expect(result?.stripped).toBe("Fix the bug."); + expect(hasChannelContext(content)).toBe(true); + }); + + it("handles a missing channel attribute", () => { + const result = extractChannelContext( + "\nbody\n", + ); + expect(result?.mention.name).toBeNull(); + expect(result?.mention.body).toBe("body"); + expect(result?.stripped).toBe(""); + }); + + it("unescapes the channel name attribute", () => { + const result = extractChannelContext( + 'x', + ); + expect(result?.mention.name).toBe("a & b"); + }); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/channelContext.ts b/packages/ui/src/features/sessions/components/session-update/channelContext.ts new file mode 100644 index 000000000..7a044ac69 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/channelContext.ts @@ -0,0 +1,44 @@ +import { unescapeXmlAttr } from "@posthog/shared"; + +// The agent's initial prompt may carry a channel's CONTEXT.md wrapped in a +// ` ... ` element (see +// buildChannelContextBlock in @posthog/core). The conversation UI collapses +// that element into a single clickable tag instead of rendering the whole body +// inline, so these helpers detect and pull it out of the stored message text. +// +// The body shown is exactly what was sent in the prompt — parsed from the +// stored event, never re-fetched from the (possibly newer) live CONTEXT.md. +const CHANNEL_CONTEXT_REGEX = + /]*)>([\s\S]*?)<\/channel_context>/; + +export interface ChannelContextMention { + /** Channel display name, or null when the prompt didn't carry one. */ + name: string | null; + /** The exact text that was sent inside the element. */ + body: string; +} + +export function hasChannelContext(content: string): boolean { + return CHANNEL_CONTEXT_REGEX.test(content); +} + +// Returns the parsed channel-context mention plus the message text with the +// element removed (so the user's own prompt renders cleanly), or null when the +// content has no channel-context element. +export function extractChannelContext(content: string): { + mention: ChannelContextMention; + stripped: string; +} | null { + const match = CHANNEL_CONTEXT_REGEX.exec(content); + if (match?.index === undefined) return null; + + const attrs = match[1] ?? ""; + const nameMatch = /channel="([^"]*)"/.exec(attrs); + const name = nameMatch ? unescapeXmlAttr(nameMatch[1]) : null; + const body = match[2].trim(); + const stripped = ( + content.slice(0, match.index) + content.slice(match.index + match[0].length) + ).trim(); + + return { mention: { name, body }, stripped }; +} diff --git a/packages/ui/src/features/task-detail/components/ChannelContextTab.tsx b/packages/ui/src/features/task-detail/components/ChannelContextTab.tsx new file mode 100644 index 000000000..482c0a00c --- /dev/null +++ b/packages/ui/src/features/task-detail/components/ChannelContextTab.tsx @@ -0,0 +1,29 @@ +import { Box, ScrollArea, Text } from "@radix-ui/themes"; +import { MarkdownRenderer } from "../../editor/components/MarkdownRenderer"; + +interface ChannelContextTabProps { + channelName: string | null; + body: string; +} + +// Renders a channel's CONTEXT.md exactly as it was sent with the task's prompt. +// Read-only snapshot — the body is carried in the tab data, not re-fetched, so +// it reflects what the agent received even if the live CONTEXT.md later changes. +export function ChannelContextTab({ + channelName, + body, +}: ChannelContextTabProps) { + return ( + + + + Sent with this task's prompt as background context + {channelName ? ` from #${channelName}` : ""}. + + + + + + + ); +} diff --git a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index e460bfe50..b91da7e4f 100644 --- a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -6,6 +6,7 @@ import type { Tab } from "../../panels/panelTypes"; import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; import { ActionPanel } from "./ActionPanel"; import { ChangesPanel } from "./ChangesPanel"; +import { ChannelContextTab } from "./ChannelContextTab"; import { FileTreePanel } from "./FileTreePanel"; import { TaskLogsPanel } from "./TaskLogsPanel"; import { TaskShellPanel } from "./TaskShellPanel"; @@ -60,6 +61,11 @@ export function TabContentRenderer({ /> ); + case "context": + return ( + + ); + case "other": switch (tab.id) { case "files": diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index b228986a1..b90ae02a0 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -1,4 +1,4 @@ -import { X } from "@phosphor-icons/react"; +import { FileText, X } from "@phosphor-icons/react"; import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { ButtonGroup } from "@posthog/quill"; @@ -67,6 +67,10 @@ interface TaskInputProps { initialModel?: string; initialMode?: string; reportAssociation?: TaskInputReportAssociation; + /** Optional channel CONTEXT.md, appended to the initial prompt as background. */ + channelContext?: string; + /** Display name of the channel the CONTEXT.md came from (for the chip). */ + channelName?: string; } export function TaskInput({ @@ -78,6 +82,8 @@ export function TaskInput({ initialModel, initialMode, reportAssociation, + channelContext, + channelName, }: TaskInputProps = {}) { const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const trpc = useHostTRPC(); @@ -143,6 +149,19 @@ export function TaskInput({ reportAssociation ?? null, ); + // Channel CONTEXT.md is included by default; the chip lets the user drop it + // from this task's prompt. Re-include whenever the source context changes + // (e.g. switching channels) so a dismissal doesn't stick across channels. + const [channelContextDismissed, setChannelContextDismissed] = useState(false); + const lastChannelContextRef = useRef(channelContext); + useEffect(() => { + if (lastChannelContextRef.current !== channelContext) { + lastChannelContextRef.current = channelContext; + setChannelContextDismissed(false); + } + }, [channelContext]); + const includeChannelContext = !!channelContext && !channelContextDismissed; + const adapter = lastUsedAdapter; const prefillRequestKey = initialPromptKey ?? initialPrompt; @@ -523,6 +542,8 @@ export function TaskInput({ ? selectedCloudEnvId : undefined, signalReportId: activeReportAssociation?.reportId, + channelContext: includeChannelContext ? channelContext : undefined, + channelName, }); const handleModeChange = useCallback( @@ -855,6 +876,27 @@ export function TaskInput({ )} + {includeChannelContext && ( +
+ Using: + + + + {channelName ? `#${channelName} ` : ""}CONTEXT.md + + + + + +
+ )} {effectiveWorkspaceMode === "cloud" && !isLoadingRepos && !hasGithubIntegration && ( diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index d9a7be8e7..6706b6845 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -60,6 +60,8 @@ interface UseTaskCreationOptions { environmentId?: string | null; sandboxEnvironmentId?: string; signalReportId?: string; + channelContext?: string; + channelName?: string; onTaskCreated?: (task: Task) => void; } @@ -137,6 +139,8 @@ export function useTaskCreation({ environmentId, sandboxEnvironmentId, signalReportId, + channelContext, + channelName, onTaskCreated, }: UseTaskCreationOptions): UseTaskCreationReturn { const [isCreatingTask, setIsCreatingTask] = useState(false); @@ -227,6 +231,8 @@ export function useTaskCreation({ sandboxEnvironmentId, signalReportId, additionalDirectories, + channelContext, + channelName, }); if (executionMode) { @@ -313,6 +319,8 @@ export function useTaskCreation({ sandboxEnvironmentId, signalReportId, additionalDirectories, + channelContext, + channelName, clearTaskInputReportAssociation, invalidateTasks, onTaskCreated,