From e00272e89588189cb1745d950836c34b58a681a5 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 23 Jun 2026 16:19:14 -0700 Subject: [PATCH 1/2] feat(canvas): collapse canvas generation instructions into a clickable chip The freeform canvas generation prompt dumped its authoring contract + publishing/data boilerplate inline, so the user message showed all of it as plain text. Wrap that boilerplate in a `` element and collapse it into a clickable chip (mirroring the channel CONTEXT.md treatment); clicking it opens a read-only snapshot in the right-side split. The user's own instruction now leads the visible message. The block is always stripped so the raw XML never leaks to flag-off viewers; the chip itself is gated behind project-bluebird. Generated-By: PostHog Code Task-Id: 933900d5-c8d2-449e-8ea9-79861613a103 --- .../core/src/panels/panelLayoutTransforms.ts | 27 ++++--- packages/core/src/panels/panelTypes.ts | 7 ++ .../features/canvas/freeformPrompt.test.ts | 39 +++++++++ .../ui/src/features/canvas/freeformPrompt.ts | 17 +++- .../panels/hooks/usePanelLayoutHooks.tsx | 9 ++- .../src/features/panels/panelLayoutStore.ts | 29 +++++-- .../session-update/UserMessage.test.tsx | 30 +++++++ .../components/session-update/UserMessage.tsx | 81 +++++++++++++------ .../session-update/canvasInstructions.test.ts | 32 ++++++++ .../session-update/canvasInstructions.ts | 33 ++++++++ .../components/CanvasInstructionsTab.tsx | 25 ++++++ .../components/TabContentRenderer.tsx | 4 + 12 files changed, 287 insertions(+), 46 deletions(-) create mode 100644 packages/ui/src/features/canvas/freeformPrompt.test.ts create mode 100644 packages/ui/src/features/sessions/components/session-update/canvasInstructions.test.ts create mode 100644 packages/ui/src/features/sessions/components/session-update/canvasInstructions.ts create mode 100644 packages/ui/src/features/task-detail/components/CanvasInstructionsTab.tsx diff --git a/packages/core/src/panels/panelLayoutTransforms.ts b/packages/core/src/panels/panelLayoutTransforms.ts index ded72feaa8..af2cc91643 100644 --- a/packages/core/src/panels/panelLayoutTransforms.ts +++ b/packages/core/src/panels/panelLayoutTransforms.ts @@ -16,7 +16,13 @@ import { removeTabFromPanel, updateTreeNode, } from "./panelTree"; -import type { PanelNode, SplitDirection, Tab, TaskLayout } from "./panelTypes"; +import type { + PanelNode, + SplitDirection, + Tab, + TabData, + TaskLayout, +} from "./panelTypes"; export const MAX_RECENT_FILES = 10; @@ -216,24 +222,21 @@ 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( +// Opens a read-only snapshot (channel CONTEXT.md, canvas generation +// instructions, …) 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 openReadonlyTabInSplit( layout: TaskLayout, tabId: string, label: string, - context: { channelName: string | null; body: string }, + data: TabData, ): Partial { const buildTab = (): Tab => ({ id: tabId, label, - data: { - type: "context", - channelName: context.channelName, - body: context.body, - }, + data, component: null, draggable: true, closeable: true, diff --git a/packages/core/src/panels/panelTypes.ts b/packages/core/src/panels/panelTypes.ts index 265ab87295..e723157bb1 100644 --- a/packages/core/src/panels/panelTypes.ts +++ b/packages/core/src/panels/panelTypes.ts @@ -34,6 +34,13 @@ export type TabData = channelName: string | null; body: string; } + | { + // A read-only snapshot of the canvas generation instructions (authoring + // contract + publishing/data rules) sent with a canvas-generation task's + // prompt, shown exactly as the agent received them. + type: "canvas-instructions"; + body: string; + } | { type: "other"; }; diff --git a/packages/ui/src/features/canvas/freeformPrompt.test.ts b/packages/ui/src/features/canvas/freeformPrompt.test.ts new file mode 100644 index 0000000000..647f4cb465 --- /dev/null +++ b/packages/ui/src/features/canvas/freeformPrompt.test.ts @@ -0,0 +1,39 @@ +import { extractCanvasInstructions } from "@posthog/ui/features/sessions/components/session-update/canvasInstructions"; +import { describe, expect, it } from "vitest"; +import { buildFreeformGenerationPrompt } from "./freeformPrompt"; + +describe("buildFreeformGenerationPrompt", () => { + const base = { + dashboardId: "dash-1", + name: "Signups", + channelName: "growth", + instruction: "add a retention chart", + }; + + it("leads with the user's instruction and wraps the contract in a tag", () => { + const prompt = buildFreeformGenerationPrompt(base); + // The visible message is the bare instruction; the boilerplate lives in the tag. + expect(prompt.startsWith("add a retention chart\n\n")).toBe(true); + expect(prompt).toContain(""); + expect(prompt).toContain(""); + + const extracted = extractCanvasInstructions(prompt); + expect(extracted?.stripped).toBe("add a retention chart"); + // The authoring contract + publishing rules are collapsed into the tag body. + expect(extracted?.body).toContain("PUBLISHING"); + expect(extracted?.body).toContain( + "desktop-file-system-canvas-partial-update", + ); + }); + + it("folds the current code into the tag when editing", () => { + const prompt = buildFreeformGenerationPrompt({ + ...base, + currentCode: "export const App = () => null;", + }); + const extracted = extractCanvasInstructions(prompt); + expect(extracted?.stripped).toBe("add a retention chart"); + expect(extracted?.body).toContain("export const App = () => null;"); + expect(extracted?.body).toContain("Edit the freeform React canvas"); + }); +}); diff --git a/packages/ui/src/features/canvas/freeformPrompt.ts b/packages/ui/src/features/canvas/freeformPrompt.ts index 685114d8ff..b735337192 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.ts @@ -38,10 +38,13 @@ export function buildFreeformGenerationPrompt(input: { ? `\n[Current code] — the canvas as it stands now. Rewrite the WHOLE file with the change applied; do not output a partial file.\n\n\`\`\`tsx\n${currentCode}\n\`\`\`\n` : ""; - return `${header} - -What the user wants: -${instruction} + // The standing authoring contract + publishing/data rules are the same + // boilerplate on every canvas generation — the user never typed them. Wrap + // them in a `` element so the conversation UI + // collapses them into a single clickable tag instead of dumping the full body + // inline (see extractCanvasInstructions). Kept after the user's instruction so + // the request leads, mirroring how channel CONTEXT.md is appended. + const instructions = `${header} ${currentBlock} Follow this authoring contract for the canvas (imports, the \`ph\` data shim, and style rules): @@ -64,4 +67,10 @@ DATA — for each metric, first SAVE an insight via the PostHog MCP insight tool over raw SQL), record the \`short_id\` it returns, and load it in the canvas with \`ph.loadInsight(short_id, { dateRange })\`. Fall back to inline \`ph.query(...)\`/HogQL only when no insight can express the metric.`; + + return `${instruction} + + +${instructions} +`; } diff --git a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx index e2929b8b85..441e2d885f 100644 --- a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx @@ -1,4 +1,9 @@ -import { ChatCenteredText, FileText, Terminal } from "@phosphor-icons/react"; +import { + ChatCenteredText, + FileText, + Scroll, + 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"; @@ -109,6 +114,8 @@ export function useTabInjection( icon = ; } else if (tab.data.type === "context") { icon = ; + } else if (tab.data.type === "canvas-instructions") { + icon = ; } } diff --git a/packages/ui/src/features/panels/panelLayoutStore.ts b/packages/ui/src/features/panels/panelLayoutStore.ts index 9a5dc00d62..14588a3869 100644 --- a/packages/ui/src/features/panels/panelLayoutStore.ts +++ b/packages/ui/src/features/panels/panelLayoutStore.ts @@ -7,7 +7,7 @@ import { closeTabsToRight as coreCloseTabsToRight, keepTab as coreKeepTab, moveTab as coreMoveTab, - openContextInSplit as coreOpenContextInSplit, + openReadonlyTabInSplit as coreOpenReadonlyTabInSplit, openTab as coreOpenTab, openTabInSplit as coreOpenTabInSplit, reorderTabs as coreReorderTabs, @@ -55,6 +55,10 @@ export interface PanelLayoutStore { taskId: string, context: { channelName: string | null; body: string }, ) => void; + openCanvasInstructionsInSplit: ( + taskId: string, + instructions: { 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; @@ -173,11 +177,26 @@ export const usePanelLayoutStore = createWithEqualityFn()( state, taskId, (layout) => - coreOpenContextInSplit( + coreOpenReadonlyTabInSplit(layout, tabId, label, { + type: "context", + channelName: context.channelName, + body: context.body, + }) as Partial, + ), + ); + }, + + openCanvasInstructionsInSplit: (taskId, instructions) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreOpenReadonlyTabInSplit( layout, - tabId, - label, - context, + "canvas-instructions", + "Canvas instructions", + { type: "canvas-instructions", body: instructions.body }, ) as Partial, ), ); 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 6f1daa2bf6..f78b4b091c 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 @@ -27,6 +27,9 @@ function renderWithFlags(node: ReactNode, bluebirdEnabled: boolean) { const PROMPT_WITH_CONTEXT = 'do the thing\n\n# Billing\n'; +const PROMPT_WITH_CANVAS_INSTRUCTIONS = + "add a retention chart\n\n\nauthoring contract\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 @@ -75,4 +78,31 @@ describe("UserMessage", () => { // The raw XML must never leak to flag-off viewers. expect(screen.queryByText(/channel_context/)).not.toBeInTheDocument(); }); + + it("shows the canvas-instructions tag when project-bluebird is enabled", () => { + vi.stubEnv("DEV", false); + renderWithFlags( + , + true, + ); + + expect(screen.getByText("add a retention chart")).toBeInTheDocument(); + expect(screen.getByText("Canvas instructions")).toBeInTheDocument(); + // The contract body is collapsed into the tag, not rendered inline. + expect(screen.queryByText("authoring contract")).not.toBeInTheDocument(); + }); + + it("hides the canvas-instructions tag but still strips the block when off", () => { + vi.stubEnv("DEV", false); + renderWithFlags( + , + false, + ); + + expect(screen.getByText("add a retention chart")).toBeInTheDocument(); + expect(screen.queryByText("Canvas instructions")).not.toBeInTheDocument(); + expect( + screen.queryByText(/canvas_generation_instructions/), + ).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 db0b50db4a..764a7bad9e 100644 --- a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx @@ -5,6 +5,7 @@ import { Copy, File, FileText, + Scroll, SlackLogo, } from "@phosphor-icons/react"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; @@ -16,6 +17,7 @@ import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; import { useFeatureFlag } from "../../../feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; import type { UserMessageAttachment } from "../../userMessageTypes"; +import { extractCanvasInstructions } from "./canvasInstructions"; import { extractChannelContext } from "./channelContext"; import { hasFileMentions, @@ -59,11 +61,13 @@ export const UserMessage = memo(function UserMessage({ animate = true, taskId, }: UserMessageProps) { - // 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. + // A channel's CONTEXT.md and the canvas generation instructions, if injected + // into this prompt, are each collapsed into a clickable tag instead of + // rendered inline; the rest of the prompt renders normally. Clicking a tag + // opens the snapshot as a split tab. The clickable tag + split tab is a + // project-bluebird feature, but we always strip the blocks so the raw + // / XML never leaks for + // flag-off viewers. const bluebirdEnabled = useFeatureFlag( PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV, @@ -72,11 +76,24 @@ export const UserMessage = memo(function UserMessage({ () => extractChannelContext(content), [content], ); - const displayContent = channelContext ? channelContext.stripped : content; + const afterChannelContext = channelContext + ? channelContext.stripped + : content; + const canvasInstructions = useMemo( + () => extractCanvasInstructions(afterChannelContext), + [afterChannelContext], + ); + const displayContent = canvasInstructions + ? canvasInstructions.stripped + : afterChannelContext; const showChannelContextTag = !!channelContext && bluebirdEnabled; + const showCanvasInstructionsTag = !!canvasInstructions && bluebirdEnabled; const openChannelContextInSplit = usePanelLayoutStore( (s) => s.openChannelContextInSplit, ); + const openCanvasInstructionsInSplit = usePanelLayoutStore( + (s) => s.openCanvasInstructionsInSplit, + ); const containsFileMentions = hasFileMentions(displayContent); const showAttachmentChips = attachments.length > 0 && !containsFileMentions; @@ -129,29 +146,45 @@ export const UserMessage = memo(function UserMessage({ ) : ( )} - {showChannelContextTag && channelContext && ( + {(showChannelContextTag || showCanvasInstructionsTag) && ( - } - label={`${ - channelContext.mention.name - ? `#${channelContext.mention.name} ` - : "" - }CONTEXT.md`} - onClick={ - taskId - ? () => - openChannelContextInSplit(taskId, { - channelName: channelContext.mention.name, - body: channelContext.mention.body, - }) - : undefined - } - /> + {showChannelContextTag && channelContext && ( + } + label={`${ + channelContext.mention.name + ? `#${channelContext.mention.name} ` + : "" + }CONTEXT.md`} + onClick={ + taskId + ? () => + openChannelContextInSplit(taskId, { + channelName: channelContext.mention.name, + body: channelContext.mention.body, + }) + : undefined + } + /> + )} + {showCanvasInstructionsTag && canvasInstructions && ( + } + label="Canvas instructions" + onClick={ + taskId + ? () => + openCanvasInstructionsInSplit(taskId, { + body: canvasInstructions.body, + }) + : undefined + } + /> + )} )} {showAttachmentChips && ( diff --git a/packages/ui/src/features/sessions/components/session-update/canvasInstructions.test.ts b/packages/ui/src/features/sessions/components/session-update/canvasInstructions.test.ts new file mode 100644 index 0000000000..34f11e2560 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/canvasInstructions.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + extractCanvasInstructions, + hasCanvasInstructions, +} from "./canvasInstructions"; + +describe("extractCanvasInstructions", () => { + it("returns null when there is no canvas-instructions element", () => { + expect(extractCanvasInstructions("just a normal prompt")).toBeNull(); + expect(hasCanvasInstructions("just a normal prompt")).toBe(false); + }); + + it("extracts the body and strips the element from the text", () => { + const content = + "What the user wants:\nadd a retention chart\n\n\nauthoring contract here\n"; + const result = extractCanvasInstructions(content); + expect(result).not.toBeNull(); + expect(result?.body).toBe("authoring contract here"); + expect(result?.stripped).toBe( + "What the user wants:\nadd a retention chart", + ); + expect(hasCanvasInstructions(content)).toBe(true); + }); + + it("strips the element even when it is the only content", () => { + const result = extractCanvasInstructions( + "\nbody\n", + ); + expect(result?.body).toBe("body"); + expect(result?.stripped).toBe(""); + }); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/canvasInstructions.ts b/packages/ui/src/features/sessions/components/session-update/canvasInstructions.ts new file mode 100644 index 0000000000..de3e6ee2b6 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/canvasInstructions.ts @@ -0,0 +1,33 @@ +// A canvas-generation task's initial prompt carries the standing authoring +// contract + publishing/data rules wrapped in a +// ` ... ` +// element (see buildFreeformGenerationPrompt). 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 regenerated. +const CANVAS_INSTRUCTIONS_REGEX = + /]*>([\s\S]*?)<\/canvas_generation_instructions>/; + +export function hasCanvasInstructions(content: string): boolean { + return CANVAS_INSTRUCTIONS_REGEX.test(content); +} + +// Returns the canvas-instructions body plus the message text with the element +// removed (so the user's own request renders cleanly), or null when the content +// has no canvas-instructions element. +export function extractCanvasInstructions(content: string): { + body: string; + stripped: string; +} | null { + const match = CANVAS_INSTRUCTIONS_REGEX.exec(content); + if (match?.index === undefined) return null; + + const body = match[1].trim(); + const stripped = ( + content.slice(0, match.index) + content.slice(match.index + match[0].length) + ).trim(); + + return { body, stripped }; +} diff --git a/packages/ui/src/features/task-detail/components/CanvasInstructionsTab.tsx b/packages/ui/src/features/task-detail/components/CanvasInstructionsTab.tsx new file mode 100644 index 0000000000..d1619a2c1f --- /dev/null +++ b/packages/ui/src/features/task-detail/components/CanvasInstructionsTab.tsx @@ -0,0 +1,25 @@ +import { Box, ScrollArea, Text } from "@radix-ui/themes"; +import { MarkdownRenderer } from "../../editor/components/MarkdownRenderer"; + +interface CanvasInstructionsTabProps { + body: string; +} + +// Renders the canvas generation instructions exactly as they were sent with the +// task's prompt. Read-only snapshot — the body is carried in the tab data, not +// re-derived, so it reflects the authoring contract the agent actually received. +export function CanvasInstructionsTab({ body }: CanvasInstructionsTabProps) { + return ( + + + + Sent with this task's prompt — the canvas authoring contract the agent + followed. + + + + + + + ); +} diff --git a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index b91da7e4f0..ed1e2a4d80 100644 --- a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -5,6 +5,7 @@ import { ReviewPage } from "../../code-review/components/ReviewPage"; import type { Tab } from "../../panels/panelTypes"; import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; import { ActionPanel } from "./ActionPanel"; +import { CanvasInstructionsTab } from "./CanvasInstructionsTab"; import { ChangesPanel } from "./ChangesPanel"; import { ChannelContextTab } from "./ChannelContextTab"; import { FileTreePanel } from "./FileTreePanel"; @@ -66,6 +67,9 @@ export function TabContentRenderer({ ); + case "canvas-instructions": + return ; + case "other": switch (tab.id) { case "files": From 23121f5013b9f5a12562b24f0c83010d122138a2 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 23 Jun 2026 16:40:03 -0700 Subject: [PATCH 2/2] feat(canvas): point the agent to the user's request at the start of the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instructions block opened with "Build a freeform React canvas …", which reads as a self-contained task and could lead the agent to under-weight the bare user instruction that now leads the message. Add a pointer in the header (inside the tag, so it stays hidden behind the chip) tying it back to the user's request. Generated-By: PostHog Code Task-Id: 933900d5-c8d2-449e-8ea9-79861613a103 --- packages/ui/src/features/canvas/freeformPrompt.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/freeformPrompt.ts b/packages/ui/src/features/canvas/freeformPrompt.ts index b735337192..08d300c431 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.ts @@ -30,9 +30,12 @@ export function buildFreeformGenerationPrompt(input: { const contract = freeformSystemPromptFor(templateId); const isEdit = !!currentCode?.trim(); + // The header points back to the user's request, which leads the message + // (outside this block). Without that pointer the agent can read the header as + // a self-contained task and under-weight the actual instruction above. const header = isEdit - ? `Edit the freeform React canvas "${name}" in the channel "${channelName}".` - : `Build a freeform React canvas "${name}" for the channel "${channelName}".`; + ? `Edit the freeform React canvas "${name}" in the channel "${channelName}", per the user's request at the start of this message.` + : `Build a freeform React canvas "${name}" for the channel "${channelName}", per the user's request at the start of this message.`; const currentBlock = isEdit ? `\n[Current code] — the canvas as it stands now. Rewrite the WHOLE file with the change applied; do not output a partial file.\n\n\`\`\`tsx\n${currentCode}\n\`\`\`\n`