From 2768798aaeaa6f43745f69ab1b46558b48cdf654 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sun, 12 Jul 2026 23:07:31 +0100 Subject: [PATCH 01/18] feat(canvas): plan-mode context creation with a channel feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the create-context dialog into a plan-mode launcher and gives the context index a real feed of "PostHog agent" system messages. Create dialog (quill Dialog): asks for a name + a description of what the context is about, then launches a cloud plan-mode session that investigates the repo and PostHog and co-builds CONTEXT.md with the user (buildContextGenerationPrompt seeds it with the description). Resolves adapter/model like the composer so cloud runs aren't rejected, and owns the task on the backend channel so it lands in the context feed (not just Recents). On failure the context is kept and the typed description is preserved for the next attempt. Context feed: after launch you land on the context index, where durable, team- visible announcements render alongside the plan task card — "created this context" (server-emitted channel_created) then "is building CONTEXT.md" (context_md_building), in order, then the card you click into for the session. Backed by the new ChannelFeedMessage endpoint (posthog/posthog); ordered via explicit timestamps. Removes the old fire-and-forget generation-task tracking (setGenerationTask / useFolderGenerationTask / GeneratingState + polling); the empty-state "Build with agent" now opens the same describe-and-plan dialog. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api-client/src/posthog-client.ts | 52 +++ packages/shared/src/domain-types.ts | 21 ++ .../canvas/components/ChannelFeedView.tsx | 97 +++++- .../canvas/components/CreateChannelModal.tsx | 300 ++++++++++++------ .../canvas/components/WebsiteChannelHome.tsx | 7 + .../canvas/components/WebsiteContext.tsx | 217 ++----------- .../ui/src/features/canvas/contextPrompt.ts | 27 +- .../canvas/hooks/useChannelFeedMessages.ts | 69 ++++ .../canvas/hooks/useFolderGenerationTask.ts | 60 ---- .../canvas/hooks/useGenerateContext.ts | 140 ++++++-- 10 files changed, 589 insertions(+), 401 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts delete mode 100644 packages/ui/src/features/canvas/hooks/useFolderGenerationTask.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 18a2aa6da6..4ac94adb1b 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -52,6 +52,8 @@ import type { ActionabilityJudgmentArtefact, AvailableSuggestedReviewer, AvailableSuggestedReviewersResponse, + ChannelFeedMessage, + ChannelFeedMessageEvent, CodeReferenceArtefact, CommitArtefact, CommitDiffResponse, @@ -2467,6 +2469,56 @@ export class PostHogAPIClient { return (await response.json()) as TaskChannel; } + // A channel's system-announcement feed (context created, CONTEXT.md being + // built), chronological. Durable + team-visible, rendered alongside task cards. + async getChannelFeed(channelId: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_channels/${channelId}/feed/`; + const response = await this.api.fetcher.fetch({ + method: "get", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch channel feed: ${response.statusText}`); + } + return (await response.json()) as ChannelFeedMessage[]; + } + + // Post a system announcement into a channel's feed. The row is authored by the + // system; the server records the requester as `author` for "Adam …" rendering. + async postChannelFeedMessage( + channelId: string, + input: { + event: ChannelFeedMessageEvent; + payload?: Record; + // Optional explicit timestamp (ISO) so a burst of announcements orders + // deterministically instead of racing on server insert time. + createdAt?: string; + }, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_channels/${channelId}/feed/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + overrides: { + body: JSON.stringify({ + event: input.event, + payload: input.payload ?? {}, + ...(input.createdAt ? { created_at: input.createdAt } : {}), + }), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to post channel feed message: ${response.statusText}`, + ); + } + return (await response.json()) as ChannelFeedMessage; + } + // Mentions of the current user across task threads, newest first. async getTaskMentions(options?: { since?: string }): Promise { const teamId = await this.getTeamId(); diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 358fa23864..c69078c7f6 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -79,6 +79,27 @@ export interface TaskChannel { created_by?: UserBasic | null; } +/** Lifecycle events a channel-feed system message can announce. */ +export type ChannelFeedMessageEvent = "context_created" | "context_md_building"; + +/** + * A durable, team-visible "PostHog agent" announcement in a channel's feed — + * rendered alongside task cards (e.g. "Adam created this context"). `author` is + * the user whose action produced the row; `author_kind` says who authored it. + * `payload` carries structured event data (e.g. `{ context_name }`) so rendering + * survives renames. + */ +export interface ChannelFeedMessage { + id: string; + channel: string; + author?: UserBasic | null; + author_kind: "human" | "system" | "agent"; + event: ChannelFeedMessageEvent | string; + payload: Record; + content: string; + created_at: string; +} + /** * One human message in a task's thread. Thread messages never reach the agent * unless the task author forwards one, which stamps the forwarded_* fields. diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index fd5a38f871..13f01b6e74 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -40,6 +40,7 @@ import { isTerminalStatus } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText"; +import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; @@ -472,23 +473,89 @@ function FeedRow({ ); } +// A card-less feed row for a synthetic "PostHog agent" announcement (context +// created, CONTEXT.md being built). Same chrome as a task row — Robot avatar, +// "PostHog / Agent" — minus the task card and reply footer. +function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) { + return ( + + + + + + + + + + + + PostHog + Agent + + {formatRelativeTimeShort(message.createdAt)} + + + + {message.text} + + + + + ); +} + +// A single feed entry, either a real task card or a synthetic system row, tagged +// with the timestamp used to interleave the two. +type FeedEntry = + | { kind: "task"; id: string; createdAt: string; task: Task } + | { + kind: "system"; + id: string; + createdAt: string; + message: ChannelFeedSystemMessage; + }; + // The Slack-style channel feed: every task kicked off in the channel, oldest // first, rendered as a kickoff message + task card. Multiplayer — the list is -// team-visible and polls for teammates' cards and status flips. +// team-visible and polls for teammates' cards and status flips. Synthetic +// "PostHog agent" system rows (context lifecycle) are interleaved by timestamp. export function ChannelFeedView({ tasks, + systemMessages, isLoading, emptyState, onOpenTask, onOpenThread, }: { tasks: Task[]; + systemMessages?: ChannelFeedSystemMessage[]; isLoading: boolean; emptyState?: React.ReactNode; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { - if (isLoading && tasks.length === 0) { + // Merge tasks + system rows into one chronological list. ISO timestamps sort + // lexically, so a plain string compare is chronological. + const entries = useMemo(() => { + const merged: FeedEntry[] = [ + ...tasks.map((task) => ({ + kind: "task" as const, + id: task.id, + createdAt: task.created_at, + task, + })), + ...(systemMessages ?? []).map((message) => ({ + kind: "system" as const, + id: message.id, + createdAt: message.createdAt, + message, + })), + ]; + merged.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); + return merged; + }, [tasks, systemMessages]); + + if (isLoading && entries.length === 0) { return (
@@ -496,7 +563,7 @@ export function ChannelFeedView({ ); } - if (tasks.length === 0) { + if (entries.length === 0) { return
{emptyState}
; } @@ -510,25 +577,29 @@ export function ChannelFeedView({ the row's top-right corner (absolute, past the row edge). Without a gutter they hug the scroll container and get clipped. */} - {tasks.map((task, index) => { - const previous = tasks[index - 1]; + {entries.map((entry, index) => { + const previous = entries[index - 1]; const showDayMarker = !previous || - dayKey(previous.created_at) !== dayKey(task.created_at); + dayKey(previous.createdAt) !== dayKey(entry.createdAt); return ( - + {showDayMarker && ( - {dayLabel(task.created_at, now)} + {dayLabel(entry.createdAt, now)} )} - + {entry.kind === "task" ? ( + + ) : ( + + )} ); })} diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index 5852bf723e..6dffa8c755 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -1,153 +1,251 @@ -import { XIcon } from "@phosphor-icons/react"; import { validateChannelName } from "@posthog/core/canvas/channelName"; -import { Button } from "@posthog/quill"; +import { + Button, + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldError, + FieldLabel, + Input, + Textarea, +} from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useGenerateContext } from "@posthog/ui/features/canvas/hooks/useGenerateContext"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { Dialog, Flex, IconButton, Text, TextField } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; -import { SquircleDashed } from "lucide-react"; import { useState } from "react"; // Matches Slack's "Create a channel" naming constraint. -const MAX_CHANNEL_NAME_LENGTH = 80; +const MAX_CONTEXT_NAME_LENGTH = 80; + +const DESCRIPTION_PLACEHOLDER = "Tell me about what this context will be about"; + +// When a create attempt creates the context but fails to launch the session, we +// stash the description here so reopening the create dialog restores it instead +// of losing what the user typed. Module-scoped so it survives the dialog (and +// its host) unmounting; consumed and cleared on the next create-dialog open. +let stashedDescription = ""; interface CreateChannelModalProps { open: boolean; onOpenChange: (open: boolean) => void; + // When set, the context already exists (e.g. the CONTEXT.md empty state): the + // name field is skipped and submitting just launches the planning session. + existingContext?: { channelId: string; channelName: string }; } +// Create-a-context dialog. Asks for a name and a short description of what the +// context is about, then launches a plan-mode session that investigates and +// builds the context's CONTEXT.md with the user. Reused from the CONTEXT.md +// empty state via `existingContext` to describe-and-plan an existing context. export function CreateChannelModal({ open, onOpenChange, + existingContext, }: CreateChannelModalProps) { + const isDescribeMode = !!existingContext; const { createChannel, isCreating } = useChannelMutations(); + const { generate, isStarting } = useGenerateContext(); const navigate = useNavigate(); const [name, setName] = useState(""); + const [description, setDescription] = useState(""); - // Reset the field each time the modal opens so a previous draft never lingers. - // Adjusted inline during render (prev-prop comparison) rather than in an - // effect, which would flash a stale value for one commit. + // Reset the fields each time the modal opens so a previous draft never + // lingers. Adjusted inline during render (prev-prop comparison) rather than in + // an effect, which would flash a stale value for one commit. const [wasOpen, setWasOpen] = useState(open); if (open !== wasOpen) { setWasOpen(open); - if (open) setName(""); + if (open) { + setName(""); + // Restore a description stashed by a prior failed launch (create mode + // only), otherwise start blank. Consuming clears the stash. + setDescription(isDescribeMode ? "" : stashedDescription); + stashedDescription = ""; + } } - const trimmed = name.trim(); - const remaining = MAX_CHANNEL_NAME_LENGTH - name.length; - const validationError = validateChannelName(trimmed); + const trimmedName = name.trim(); + const trimmedDescription = description.trim(); + const remaining = MAX_CONTEXT_NAME_LENGTH - name.length; + const nameError = isDescribeMode ? null : validateChannelName(trimmedName); + + const busy = isCreating || isStarting; + const canSubmit = + !busy && + !!trimmedDescription && + (isDescribeMode || (!!trimmedName && !nameError)); const submit = async () => { - if (!trimmed || validationError || isCreating) return; - let channel: Awaited>; - try { - channel = await createChannel(trimmed); - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "create", - surface: "sidebar", - channel_id: channel.id, - success: true, - }); - } catch (error) { - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "create", - surface: "sidebar", - success: false, - }); - toast.error("Couldn't create context", { - description: error instanceof Error ? error.message : String(error), - }); + if (!canSubmit) return; + + // Resolve the target context: an existing one, or create it now. The + // context must exist before we can seed a session against its id. + let contextId: string; + let contextName: string; + if (existingContext) { + contextId = existingContext.channelId; + contextName = existingContext.channelName; + } else { + try { + const channel = await createChannel(trimmedName); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "create", + surface: "sidebar", + channel_id: channel.id, + success: true, + }); + contextId = channel.id; + contextName = trimmedName; + } catch (error) { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "create", + surface: "sidebar", + success: false, + }); + toast.error("Couldn't create context", { + description: error instanceof Error ? error.message : String(error), + }); + return; + } + } + + track(ANALYTICS_EVENTS.CONTEXT_ACTION, { + action_type: "generate_started", + channel_id: contextId, + }); + const task = await generate({ + channelId: contextId, + channelName: contextName, + description: trimmedDescription, + }); + + if (!task) { + // The session failed to start (generate() already toasted the details). + // In create mode the context was still created — close and drop the user + // on its home so they can retry from the empty state. Stash the typed + // description so reopening the create dialog restores it. In describe mode + // the context already existed; keep the dialog open (state intact) for a + // clean retry. + if (!existingContext) { + stashedDescription = trimmedDescription; + onOpenChange(false); + void navigate({ + to: "/website/$channelId", + params: { channelId: contextId }, + }); + } return; } + + // Launch succeeded. The feed announcements (channel_created, server-emitted; + // context_md_building, posted by generate against the backend channel) are + // handled where the backend channel id is known — the dialog just navigates. + // + // Land on the context index (its feed), where the announcements and the plan + // task card show. The user clicks the card to open the session. onOpenChange(false); - // Open the new channel's static homepage. void navigate({ to: "/website/$channelId", - params: { channelId: channel.id }, + params: { channelId: contextId }, }); }; return ( - { - if (!isCreating) onOpenChange(next); + if (!busy) onOpenChange(next); }} > - - - - Create a context - - - - - - - - - - - Name - - setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void submit(); - } - }} - > - - - - - - {remaining} - - - - {validationError && ( - - {validationError} - + + + + {isDescribeMode ? "Build this context" : "Create a context"} + + + {isDescribeMode + ? `Describe what ${existingContext.channelName} is about — an agent plans and builds its CONTEXT.md with you.` + : "Name it and describe what it's about — an agent plans and builds its CONTEXT.md with you."} + + + + + {!isDescribeMode && ( + + Name + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + /> + {nameError ? ( + {nameError} + ) : ( + + {remaining} left + + )} + )} - - Each context gets its own dashboards, tasks, and settings. Use a - name that's easy to find. - - - + + Description +