feat(canvas): plan-mode context creation with a channel feed#3370
feat(canvas): plan-mode context creation with a channel feed#3370adamleithp wants to merge 4 commits into
Conversation
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) <noreply@anthropic.com>
The feed-message endpoint (posthog#70320) isn't deployed, so the feed opened with the plan-task card instead of a creation announcement. Synthesize the opener from TaskChannel.created_at/created_by — canonical, always sorts first, deploy-independent — and filter server-emitted channel_created/context_created rows so nothing duplicates once the backend lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
|
Reviews (1): Last reviewed commit: "fix(canvas): derive 'created this contex..." | Re-trigger Greptile |
| const task = await generate({ | ||
| channelId: contextId, | ||
| channelName: contextName, | ||
| description: trimmedDescription, | ||
| }); |
There was a problem hiding this comment.
Planning Failure Escapes Dialog
When the cloud usage preflight or another task-start dependency throws, generate() rejects instead of returning null, and this submit path has no try/catch. In create mode the context has already been created, so the user gets no toast, no stashed description, and no navigation path to retry.
Rule Used: Always wrap asynchronous calls that may fail, such... (source)
Learned From
PostHog/posthog#32098
| adapter: adapter ?? "claude", | ||
| model: currentModel, | ||
| channelId: backendChannelId, |
There was a problem hiding this comment.
For a default cloud run, currentModel can be undefined when lastUsedAdapter is unset or the preview config has not loaded yet. The task creation path then sends no usable model for a cloud task, so first-time users can create a context but fail to start the planning session.
| message, | ||
| })), | ||
| ]; | ||
| merged.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); |
There was a problem hiding this comment.
When a system message and task share the same timestamp, this comparator returns 1 instead of 0, which breaks the sort contract. Feed rows created in the same burst can flip order across refreshes, even though the feed is meant to be chronological.
| merged.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); | |
| merged.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); |
| taskDescription: `Build CONTEXT.md for ${channelName}`, | ||
| workspaceMode, | ||
| adapter: adapter ?? "claude", | ||
| model: currentModel, |
There was a problem hiding this comment.
[correctness, CONFIRMED] currentModel is undefined until usePreviewConfig's async tRPC fetch resolves — and permanently when no cloud region is set — and canSubmit in the dialog never gates on it. A user clicking "Create & plan" in that window submits a cloud task with no model, which the cloud runtime rejects: the context is created but orphaned, with only a generic toast.
useGenerateFreeformCanvas already solves exactly this: it falls back to modelResolver.resolveDefaultModel and hard-stops with a "No model is configured for cloud runs" toast when none resolves. Do the same here.
| (client) => client.getChannelFeed(channelId as string), | ||
| { | ||
| enabled: !!channelId, | ||
| refetchInterval: CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS, |
There was a problem hiding this comment.
[efficiency, CONFIRMED] Against prod this endpoint 404s today (posthog#70320 not deployed), getChannelFeed throws on non-ok, and this is a numeric refetchInterval with TanStack's default retry: 3 (the app QueryClient only overrides staleTime/refetchOnWindowFocus). TanStack keeps polling numeric intervals while the query is in error state — so every open channel view fires ~4 requests of 404 every 5s, forever.
Pass retry: false and make the interval a function that stops on error, e.g. refetchInterval: (q) => (q.state.status === "error" ? false : CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS), or gate polling until the first success.
| channelName={channelName} | ||
| regenerate={!!stoppedTaskId} | ||
| /> | ||
| <GenerateWithAgent channelId={channelId} channelName={channelName} /> |
There was a problem hiding this comment.
[correctness/regression, CONFIRMED] Two behaviors the deleted useFolderGenerationTask flow provided are not re-established here:
- No live refresh: the old code polled the published file every 5s while a generation ran;
useFolderInstructionshas norefetchInterval, so a user sitting on this tab never sees the agent's published CONTEXT.md until they navigate away and back. - No double-start guard: the old tab rendered a
GeneratingStateinstead of the button while a shared server-side generation task ran ("any user sees an in-progress generation and won't double-start it"). Now "Build with agent" is unconditional, so a teammate (or a second window) can start a competing plan session for the same context.
The durable context_md_building feed row only shows in the channel-home feed, not on this tab — it could double as the guard signal here.
| // Restore a description stashed by a prior failed launch (create mode | ||
| // only), otherwise start blank. Consuming clears the stash. | ||
| setDescription(isDescribeMode ? "" : stashedDescription); | ||
| stashedDescription = ""; |
There was a problem hiding this comment.
[correctness, CONFIRMED] The stash's only write path is the create-mode launch failure, which navigates to the context home — where the natural retry is the CONTEXT.md empty state's "Build with agent", which reopens this component in describe mode. This open branch then blanks the field (isDescribeMode ? "") and clears stashedDescription without consuming it, so the advertised restore never survives the natural flow. And because the variable is module-global and keyed to nothing, a stash from failed context A can pre-fill a later, unrelated create dialog for context B.
Consider only consuming/clearing the stash in create mode, and keying it (or better: pre-filling the describe-mode dialog with it, since that's where the user actually retries).
| message, | ||
| })), | ||
| ]; | ||
| merged.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); |
There was a problem hiding this comment.
[correctness, PLAUSIBLE] This comparator is inconsistent: equal timestamps return 1 both ways (compare(a,b) === compare(b,a) === 1), which defeats the ES2019 stable-sort guarantee for ties — and ties are realistic here (the -1ms offset in useGenerateContext exists precisely because a system row and its task can collide; server second-precision truncation too). Tied entries get an arbitrary rather than insertion-order arrangement.
merged.sort((a, b) => a.createdAt.localeCompare(b.createdAt)) is both consistent and chronological for ISO strings.
| // display name onto its backend channel the way useBackendChannel does: the "me" | ||
| // folder maps to the personal channel; any other name resolve-or-creates its | ||
| // public channel. Without this the task only appears in Recents, not the feed. | ||
| async function resolveBackendChannelId( |
There was a problem hiding this comment.
[reuse/altitude, CONFIRMED] This re-implements the folder-name → backend-channel mapping that useBackendChannel already encodes (its own comment admits it: "the way useBackendChannel does"). Two copies of the me-vs-public branch + normalization will drift on the next mapping change. Worth extracting a shared imperative resolver next to useBackendChannel.
Deeper issue both copies share: the mapping is keyed on display name, so renaming a context orphans its feed — the next generate resolve-or-creates a fresh empty backend channel while all prior tasks/announcements stay on the old one. Fine to punt, but worth a tracking note; the channels-architecture plan's workstream A (unified channel identity) is the real fix.
| const currentModel = | ||
| modelOption?.type === "select" ? modelOption.currentValue : undefined; | ||
|
|
||
| const generate = useCallback( |
There was a problem hiding this comment.
[conventions, CONFIRMED] Root CLAUDE.md rule 5: "Hooks wrap exactly one query, mutation, subscription, or store selector. Multi-source orchestration belongs in a service method." generate chains channel resolution → createTask → fileTask → feed-message post → three cache invalidations. The task-creation orchestration (resolve channel, create task, announce) belongs in a @posthog/core service method; the hook should wrap that one call plus the query invalidations.
| <div className="flex flex-col items-center gap-2 text-center"> | ||
| <Heading className="font-bold text-2xl"> | ||
| {channelName === PERSONAL_CHANNEL_NAME | ||
| ? "Welcome to your own personal context" |
There was a problem hiding this comment.
[conventions, CONFIRMED] Root CLAUDE.md: "Empty/placeholder/loading screens (canvas and elsewhere) are a @posthog/quill <Empty> (EmptyHeader → EmptyMedia variant=\"icon\" → EmptyTitle → EmptyDescription, then EmptyContent for CTAs). Don't hand-roll the centered Flex + dashed icon box." This empty state is a hand-rolled centered flex with Heading/Text + custom suggestion cards.
| } | ||
|
|
||
| /** Lifecycle events a channel-feed system message can announce. */ | ||
| export type ChannelFeedMessageEvent = "context_created" | "context_md_building"; |
There was a problem hiding this comment.
[simplification, CONFIRMED] context_created is never posted anywhere in this repo (only context_md_building is), and useChannelFeedMessages explicitly filters it out via CREATION_EVENTS. Advertising it in the union invites a developer to post an event that gets silently dropped from the feed. Narrow the union to "context_md_building" until a second event ships.
- Resolve the cloud model via modelResolver.resolveDefaultModel (like the freeform-canvas flow) instead of trusting the composer picker's async state; hard-stop with a toast when no model resolves, before the task (and orphaned context) is created. - Stop polling the channel feed once the query errors and disable retries — the endpoint 404s on prod until posthog#70320 deploys, and the old numeric interval + default retries streamed ~4 requests per 5s forever. - Make the feed merge comparator consistent for equal timestamps. - Only consume the stashed create-dialog description in create mode, so the describe-mode open the failed-launch flow lands on doesn't wipe it. - Narrow ChannelFeedMessageEvent to the one event actually posted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
Contexts had no guided creation flow and their feed was task cards only — no lifecycle announcements, and no way to see who created a context or that its CONTEXT.md is being built.
Built on this posthog/posthog PR
PostHog/posthog#70320
Changes
New create context/channel dialog
New context.md task (needs input, not ideal, but working for now)
Created context
Plan-mode context creation (
CreateChannelModal,useGenerateContext)existingContext) to describe-and-plan an existing context.Channel feed system rows (
ChannelFeedView,useChannelFeedMessages)created_at/created_by): canonical, always sorts first, and renders even before the feed-message endpoint (feat(tasks): channel feed messages — durable, multiplayer system announcements posthog#70320) deploys. Personal ("me") contexts get no creation row.channel_created/context_createdrows are filtered out so nothing duplicates once the backend lands.Rename — channels are now "contexts" across the UI copy (sidebar, dialogs, menus).
How did you test this?
pnpm --filter @posthog/ui typecheck, biome clean.adams-playgroundfeed opens with "Peter Kirkham created this context" above the task cards; "me" has no creation row; empty contexts keep the welcome state.channel_createdemit) tested in feat(tasks): channel feed messages — durable, multiplayer system announcements posthog#70320 (7→10 passing API tests).🤖 Generated with Claude Code