Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions packages/core/src/panels/panelLayoutTransforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<TaskLayout> {
const buildTab = (): Tab => ({
id: tabId,
label,
data: {
type: "context",
channelName: context.channelName,
body: context.body,
},
data,
component: null,
draggable: true,
closeable: true,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/panels/panelTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
};
Expand Down
39 changes: 39 additions & 0 deletions packages/ui/src/features/canvas/freeformPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -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("<canvas_generation_instructions>");
expect(prompt).toContain("</canvas_generation_instructions>");

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");
});
});
24 changes: 18 additions & 6 deletions packages/ui/src/features/canvas/freeformPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,24 @@ 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`
: "";

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 `<canvas_generation_instructions>` 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):
Expand All @@ -64,4 +70,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}

<canvas_generation_instructions>
${instructions}
</canvas_generation_instructions>`;
}
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -109,6 +114,8 @@ export function useTabInjection(
icon = <ActionTabIcon actionId={tab.data.actionId} />;
} else if (tab.data.type === "context") {
icon = <FileText size={14} />;
} else if (tab.data.type === "canvas-instructions") {
icon = <Scroll size={14} />;
}
}

Expand Down
29 changes: 24 additions & 5 deletions packages/ui/src/features/panels/panelLayoutStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -173,11 +177,26 @@ export const usePanelLayoutStore = createWithEqualityFn<PanelLayoutStore>()(
state,
taskId,
(layout) =>
coreOpenContextInSplit(
coreOpenReadonlyTabInSplit(layout, tabId, label, {
type: "context",
channelName: context.channelName,
body: context.body,
}) as Partial<TaskLayout>,
),
);
},

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<TaskLayout>,
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ function renderWithFlags(node: ReactNode, bluebirdEnabled: boolean) {
const PROMPT_WITH_CONTEXT =
'do the thing\n<channel_context channel="billing">\n# Billing\n</channel_context>';

const PROMPT_WITH_CANVAS_INSTRUCTIONS =
"add a retention chart\n\n<canvas_generation_instructions>\nauthoring contract\n</canvas_generation_instructions>";

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
Expand Down Expand Up @@ -75,4 +78,31 @@ describe("UserMessage", () => {
// The raw <channel_context> 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(
<UserMessage content={PROMPT_WITH_CANVAS_INSTRUCTIONS} taskId="task-1" />,
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(
<UserMessage content={PROMPT_WITH_CANVAS_INSTRUCTIONS} taskId="task-1" />,
false,
);

expect(screen.getByText("add a retention chart")).toBeInTheDocument();
expect(screen.queryByText("Canvas instructions")).not.toBeInTheDocument();
expect(
screen.queryByText(/canvas_generation_instructions/),
).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Copy,
File,
FileText,
Scroll,
SlackLogo,
} from "@phosphor-icons/react";
import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared";
Expand All @@ -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,
Expand Down Expand Up @@ -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 <channel_context> 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
// <channel_context>/<canvas_generation_instructions> XML never leaks for
// flag-off viewers.
const bluebirdEnabled = useFeatureFlag(
PROJECT_BLUEBIRD_FLAG,
import.meta.env.DEV,
Expand All @@ -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;
Expand Down Expand Up @@ -129,29 +146,45 @@ export const UserMessage = memo(function UserMessage({
) : (
<MarkdownRenderer content={displayContent} />
)}
{showChannelContextTag && channelContext && (
{(showChannelContextTag || showCanvasInstructionsTag) && (
<Flex
wrap="wrap"
gap="1"
className={displayContent ? "mt-1.5" : ""}
>
<MentionChip
icon={<FileText size={12} />}
label={`${
channelContext.mention.name
? `#${channelContext.mention.name} `
: ""
}CONTEXT.md`}
onClick={
taskId
? () =>
openChannelContextInSplit(taskId, {
channelName: channelContext.mention.name,
body: channelContext.mention.body,
})
: undefined
}
/>
{showChannelContextTag && channelContext && (
<MentionChip
icon={<FileText size={12} />}
label={`${
channelContext.mention.name
? `#${channelContext.mention.name} `
: ""
}CONTEXT.md`}
onClick={
taskId
? () =>
openChannelContextInSplit(taskId, {
channelName: channelContext.mention.name,
body: channelContext.mention.body,
})
: undefined
}
/>
)}
{showCanvasInstructionsTag && canvasInstructions && (
<MentionChip
icon={<Scroll size={12} />}
label="Canvas instructions"
onClick={
taskId
? () =>
openCanvasInstructionsInSplit(taskId, {
body: canvasInstructions.body,
})
: undefined
}
/>
)}
</Flex>
)}
{showAttachmentChips && (
Expand Down
Loading
Loading