Skip to content

feat(canvas): plan-mode context creation with a channel feed#3370

Open
adamleithp wants to merge 4 commits into
mainfrom
ux/context-home-page
Open

feat(canvas): plan-mode context creation with a channel feed#3370
adamleithp wants to merge 4 commits into
mainfrom
ux/context-home-page

Conversation

@adamleithp

@adamleithp adamleithp commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

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

2026-07-13 00 18 03

New context.md task (needs input, not ideal, but working for now)

image image image

Still shows in progress (different bug)

image

Created context

2026-07-13 00 30 36

Plan-mode context creation (CreateChannelModal, useGenerateContext)

  • "Create a context" now asks for a name + description and launches a plan-mode cloud session that investigates and co-builds the context's CONTEXT.md with the user. Reused from the CONTEXT.md empty state (existingContext) to describe-and-plan an existing context.
  • The task is filed onto the context and owned by its backend channel, so it lands in the feed (not just Recents).

Channel feed system rows (ChannelFeedView, useChannelFeedMessages)

  • The feed now interleaves durable "PostHog agent" system rows with task cards, sorted by timestamp.
  • The opener — "Ann created this context" — is derived from the backend channel row itself (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.
  • "Ann is building CONTEXT.md for X" is posted as a durable feed message against the backend channel (timestamped just before the task so it sorts above the card). Requires posthog#70320 server-side; silently no-ops until it deploys.
  • Server-emitted channel_created/context_created rows 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?

🤖 Generated with Claude Code

adamleithp and others added 3 commits July 12, 2026 13:02
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>
@trunk-io

trunk-io Bot commented Jul 12, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

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

@greptile-apps

greptile-apps Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(canvas): derive 'created this contex..." | Re-trigger Greptile

Comment on lines +125 to +129
const task = await generate({
channelId: contextId,
channelName: contextName,
description: trimmedDescription,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

Comment on lines +106 to +108
adapter: adapter ?? "claude",
model: currentModel,
channelId: backendChannelId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cloud Task Missing Model

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Equal Timestamps Reorder Rows

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.

Suggested change
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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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} />

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness/regression, CONFIRMED] Two behaviors the deleted useFolderGenerationTask flow provided are not re-established here:

  1. No live refresh: the old code polled the published file every 5s while a generation ran; useFolderInstructions has no refetchInterval, so a user sitting on this tab never sees the agent's published CONTEXT.md until they navigate away and back.
  2. No double-start guard: the old tab rendered a GeneratingState instead 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 = "";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 → createTaskfileTask → 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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[conventions, CONFIRMED] Root CLAUDE.md: "Empty/placeholder/loading screens (canvas and elsewhere) are a @posthog/quill <Empty> (EmptyHeaderEmptyMedia variant=\"icon\"EmptyTitleEmptyDescription, 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.

Comment thread packages/shared/src/domain-types.ts Outdated
}

/** Lifecycle events a channel-feed system message can announce. */
export type ChannelFeedMessageEvent = "context_created" | "context_md_building";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant