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
15 changes: 10 additions & 5 deletions packages/core/src/canvas/canvasTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,18 @@ const FREEFORM_BASE = [
const FREEFORM_STYLE = [
"",
"STYLE:",
"- You may use inline `style` objects, `@posthog/quill` components, or a `<style>` block in your JSX. Write real, specific copy — never lorem ipsum.",
"- STYLING — use Tailwind utility classes (the sandbox loads Tailwind) and `@posthog/quill` components for ALL styling. Do NOT reach for inline `style={{…}}` for anything a class can express (color, spacing, sizing, layout, borders, radius, typography) — agents over-use `style` and it bypasses the theme. Reserve `style` ONLY for a genuinely dynamic runtime value that no utility can name (e.g. a width/height computed at runtime). For a fixed size use an arbitrary-value utility instead (e.g. `h-[280px] w-full`), not `style`. A `<style>` block is fine for keyframes or complex selectors. Write real, specific copy — never lorem ipsum.",
'- SPECIAL CHARACTERS: write Unicode glyphs (curly quotes “ ” ‘ ’, ellipsis …, middot ·, en/em dashes – —, arrows, emoji) as the LITERAL character directly in the source. Do NOT use `\\uXXXX` escape sequences in JSX text or attribute values — `\\u` escapes are only decoded inside JavaScript string/template literals, so in JSX text they render verbatim (e.g. `\\u201c` shows up as the text `u201c`). If you must use an escape, wrap it in an expression container: `{"\\u2026"}`.',
"- Build ANYTHING the user asks: dashboards, tools, forms, reports, small apps. Keep it self-contained in the one file.",
"",
"THEME (light / dark) — the canvas renders in the user's current PostHog theme, and that can switch at runtime. The host puts a `.dark` class on the document root in dark mode (exactly like the main app), so your styles MUST adapt to both:",
"- Prefer `@posthog/quill` components and the design tokens — they already flip between light and dark automatically, so you get correct theming for free.",
"- For any custom styling, derive colors from the Quill CSS variables — e.g. `var(--background)`, `var(--foreground)`, `var(--card)`, `var(--card-foreground)`, `var(--border)`, `var(--muted-foreground)`, `var(--primary)` — or the matching Tailwind token utilities (`bg-background`, `text-foreground`, `bg-card`, `border-border`, `text-muted-foreground`). These all track the theme.",
'- NEVER hardcode a light-only color (e.g. `#fff`, `#111`, `color: black`, `background: white`) — it looks broken in the other theme. If you must special-case dark mode, use the `dark:` Tailwind variant (e.g. `className="bg-white dark:bg-zinc-900"`), which is wired to the `.dark` class.',
"- COLOR ONLY from Quill's design-token Tailwind utilities — never an invented or hardcoded color. The sandbox maps these (each tracks light/dark automatically): surfaces `bg-background` `bg-card` `bg-muted` `bg-primary` `bg-success` `bg-warning` `bg-info` `bg-destructive`; neutral text/icons `text-foreground` `text-muted-foreground` `text-card-foreground`; readable status accents `text-success-foreground` `text-warning-foreground` `text-info-foreground` `text-destructive-foreground`; borders/rings `border-border` `ring-ring`; state fills `bg-fill-hover` `bg-fill-selected`.",
"- STATUS TOKENS (success / warning / info / destructive) INVERT the usual convention: the BARE token is a PALE BACKGROUND fill, and `-foreground` is the STRONG, READABLE color. So colored TEXT or ICONS ALWAYS use the `-foreground` utility — `text-success-foreground` for an up delta, `text-destructive-foreground` for a down delta. NEVER bare `text-success` / `text-destructive` for text: that is the pale fill and is nearly invisible (this is the #1 mistake). A filled pill/badge uses the pair `bg-success text-success-foreground` (fill + its readable content). Never `bg-*-foreground`.",
'- PRIMARY follows the NORMAL convention instead: `bg-primary` is the strong brand color with `text-primary-foreground` (white) on it; `text-primary` is the brand color used as text. (Prefer the Quill `Badge` component for deltas — `variant="success"` / `"destructive"` — so you don\'t hand-pick any of this.)',
"- DO NOT use `bg-secondary` / `text-secondary` / `bg-accent` / `bg-popover` — those tokens are NOT defined in the canvas and render transparent. Stick to the tokens listed above.",
"- Only drop to a CSS variable (`var(--primary)`, `var(--border)`, `var(--muted-foreground)`) where a className can't reach — i.e. a prop that takes a raw color string (recharts `stroke`/`fill`), never in `className` or `style` where a utility works.",
'- NEVER hardcode a light-only color (e.g. `#fff`, `#111`, `color: black`, `background: white`) — it looks broken in the other theme. If you must special-case dark mode, use the `dark:` Tailwind variant (e.g. `className="bg-card dark:bg-muted"`), which is wired to the `.dark` class.',
'- recharts strokes/fills must use the token CSS variables too (e.g. `stroke="var(--primary)"`, grid/axis in `var(--border)` / `var(--muted-foreground)`) so charts adapt as well.',
"",
"Do NOT write files, edit code on disk, or run shell commands. Your entire app is the single fenced tsx block in your reply.",
Expand Down Expand Up @@ -114,8 +118,9 @@ const FREEFORM_QUILL_RULES = [
// React templates; correctness rules mirror the json-render tier's window logic.
const FREEFORM_DATE_CONTROL_RULES = [
"DATE WINDOW — your app owns the date control. Render Quill's `DateTimePicker` (the real PostHog date picker) — NEVER a custom Select, a native `<input type=date>`, or a hand-rolled control.",
'- Wire it up exactly like this: `import { Button, DateTimePicker, Popover, PopoverContent, PopoverTrigger, quickRanges } from "@posthog/quill"`. Seed window state from a quick range: `const def = quickRanges.find((r) => r.name === "Last 30 days") ?? quickRanges[0]; const [win, setWin] = useState({ start: def.rangeSetter(new Date()), end: new Date(), range: def });`. Render a `Popover` whose `PopoverTrigger` is a Quill `Button` (label `{win.range.name}`), with `<DateTimePicker compact value={win} onApply={(v) => { setWin(v); setOpen(false); }} onCancel={() => setOpen(false)} />` inside `PopoverContent`. Do NOT import the `DateTimeValue` TYPE — the sandbox strips types at runtime; use the values only.',
"- ALWAYS pass the `compact` prop to `DateTimePicker`. Without it the picker auto-detects screen size via a media query against the iframe viewport (which is full-width), picks the WIDE dual-calendar layout, and overflows the popover. `compact` forces the single-calendar layout that fits a popover.",
'- Wire it up exactly like this: `import { Button, DateTimePicker, Popover, PopoverContent, PopoverTrigger, quickRanges } from "@posthog/quill"`. Seed window state from a quick range: `const def = quickRanges.find((r) => r.name === "Last 30 days") ?? quickRanges[0]; const [win, setWin] = useState({ start: def.rangeSetter(new Date()), end: new Date(), range: def });`. Render a `Popover` whose `PopoverTrigger` is a Quill `Button` (label `{win.range.name}`), with `<DateTimePicker value={win} onApply={(v) => { setWin(v); setOpen(false); }} onCancel={() => setOpen(false)} />` inside `<PopoverContent className="w-auto p-0">`. Do NOT import the `DateTimeValue` TYPE — the sandbox strips types at runtime; use the values only.',
"- Do NOT pass the `compact` prop and do NOT constrain the picker's width: `DateTimePicker` self-adjusts (it media-queries its own window and drops to the single-calendar layout in the narrow canvas pane). Forcing `compact` or a fixed width is unnecessary and fights its responsiveness.",
'- ALWAYS give `PopoverContent` exactly `className="w-auto p-0"` — its default fixed width + padding squeeze the self-sizing picker and clip the quick-range tabs. That is the ONLY className it may have; add NOTHING (no `className`, `style`, or width) to `DateTimePicker` or `PopoverTrigger`. The picker is fully styled and self-sizing — let it be. The ONLY props on `DateTimePicker` are `value` / `onApply` / `onCancel`.',
"- Drive your data `useEffect` off `win` and re-run EVERY query when it changes.",
"- PREFERRED — pass the window to `ph.loadInsight` as `dateRange`: `ph.loadInsight(shortId, { dateRange: { date_from: win.start.toISOString(), date_to: win.end.toISOString() } })`. The saved insight re-scopes to your window — you write NO time SQL. (A SAVED SQL insight may ignore this and use its own window; that's a reason to express metrics as insight query types, not SQL.)",
"- If you fall back to an ad-hoc typed node, feed the window into its `dateRange` the same way: `dateRange: { date_from: win.start.toISOString(), date_to: win.end.toISOString() }`. The query runner handles timezone/bucketing/half-open — no time SQL.",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/canvas/freeformSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export type CanvasDataResult = z.infer<typeof canvasDataResultSchema>;
export const canvasLoadInsightInput = z.object({
shortId: z.string().min(1),
dateRange: z
.object({ date_from: z.string(), date_to: z.string() })
.object({ date_from: z.string().nullish(), date_to: z.string().nullish() })
.optional(),
});
export type CanvasLoadInsightInput = z.infer<typeof canvasLoadInsightInput>;
Expand Down
186 changes: 186 additions & 0 deletions packages/core/src/canvas/freeformStarter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// A known-good STARTER scaffold for a freeform (React) canvas. Instead of
// authoring the whole single-file app from scratch every time, the generation
// path seeds this working baseline as the agent's starting point (on by default;
// opt out via the generate bar toggle). It already wires the pieces that are
// easy to get wrong — the date picker (self-sizing, no `compact`), theme-aware
// tokens, per-card loading skeletons, and reading a TYPED-NODE result correctly
// — so the agent edits a compiling app instead of re-deriving boilerplate.
//
// Stored as a string (like the prompt contracts) — it is injected into the
// generation prompt, not compiled here. It imports ONLY whitelisted packages
// (see freeformWhitelist) and uses the runtime `ph` global for data. The sample
// metric is "all events" (math:total, event:null) so it renders on ANY project;
// the agent replaces it with the user's real metrics.
export const FREEFORM_STARTER_CODE = `import React, { useEffect, useState } from "react";
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
DateTimePicker,
Heading,
Popover,
PopoverContent,
PopoverTrigger,
quickRanges,
SkeletonText,
} from "@posthog/quill";
import { RefreshCw } from "lucide-react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";

// Starter scaffold. Replace the sample "total events" metric and the layout
// with what the user asked for — but KEEP the wiring below (date picker, theme
// tokens, skeletons, typed-node result reading), it is already correct.
export default function Canvas() {
const def =
quickRanges.find((r) => r.name === "Last 30 days") ?? quickRanges[0];
const [win, setWin] = useState({
start: def.rangeSetter(new Date()),
end: new Date(),
range: def,
});
const [open, setOpen] = useState(false);

const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [series, setSeries] = useState([]);
// Refresh plumbing: bump this nonce to re-run the data effect on demand. The
// effect already re-runs when the date window changes; the Refresh button just
// forces a re-run with the same window.
const [nonce, setNonce] = useState(0);

useEffect(() => {
let cancelled = false;
setLoading(true);
// PREFERRED data path: a TYPED query node, computed by PostHog's own runner
// so the numbers match the UI exactly. \`event: null\` = all events (works on
// any project). Swap in the real metric — ideally a SAVED insight loaded
// with \`ph.loadInsight(shortId, { dateRange })\`.
ph.query({
kind: "TrendsQuery",
series: [
{ kind: "EventsNode", event: null, name: "All events", math: "total" },
],
dateRange: {
date_from: win.start.toISOString(),
date_to: win.end.toISOString(),
},
})
.then((res) => {
if (cancelled) return;
// Typed-node result: \`results\` is an array of SERIES OBJECTS, not rows.
const s = res.results[0] ?? {};
setTotal(s.count ?? 0);
setSeries(
(s.days ?? []).map((day, i) => ({ day, value: s.data?.[i] ?? 0 })),
);
setLoading(false);
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [win, nonce]);

return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<Heading size="xl" className="mb-4">Canvas</Heading>
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={<Button variant="outline">{win.range.name}</Button>}
/>
{/* PopoverContent needs w-auto p-0 so its default fixed width +
padding don't squeeze the self-sizing picker (which clips the
quick-range tabs). No other styles on it or the picker. */}
<PopoverContent className="w-auto p-0">
<DateTimePicker
value={win}
onApply={(v) => {
setWin(v);
setOpen(false);
}}
onCancel={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
<Button
variant="outline"
disabled={loading}
onClick={() => setNonce((n) => n + 1)}
>
<RefreshCw size={14} className={loading ? "animate-spin" : undefined} />
Refresh
</Button>
</div>
</div>

<div className="grid gap-4 md:grid-cols-3">
<Card size="sm">
<CardHeader>
<CardTitle>Total events</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<SkeletonText lines={1} className="text-3xl" />
) : (
<Heading size="2xl">{total.toLocaleString()}</Heading>
)}
</CardContent>
</Card>
</div>

<Card size="sm">
<CardHeader>
<CardTitle>Events over time</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<SkeletonText lines={6} />
) : (
<div className="h-[280px] w-full">
<ResponsiveContainer>
<LineChart data={series}>
<CartesianGrid
stroke="var(--border)"
strokeDasharray="3 3"
/>
<XAxis
dataKey="day"
stroke="var(--muted-foreground)"
tick={{ fontSize: 12 }}
/>
<YAxis
stroke="var(--muted-foreground)"
tick={{ fontSize: 12 }}
/>
<Tooltip />
<Line
type="monotone"
dataKey="value"
stroke="var(--primary)"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
</div>
);
}
`;
2 changes: 1 addition & 1 deletion packages/core/src/canvas/posthogApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export interface InsightFetchResult {
export async function fetchInsightByShortId(
authService: AuthService,
shortId: string,
opts?: { dateRange?: { date_from: string; date_to: string } },
opts?: { dateRange?: { date_from?: string | null; date_to?: string | null } },
): Promise<InsightFetchResult> {
const { apiHost } = await authService.getValidAccessToken();
const projectId = authService.getState().currentProjectId;
Expand Down
52 changes: 39 additions & 13 deletions packages/ui/src/features/canvas/freeform/FreeformGenerateBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useGenerateFreeformCanvas } from "@posthog/ui/features/canvas/hooks/useGenerateFreeformCanvas";
import { PromptInput } from "@posthog/ui/features/message-editor/components/PromptInput";
import type { EditorHandle } from "@posthog/ui/features/message-editor/types";
import { forwardRef } from "react";
import { forwardRef, useState } from "react";

// Composer that kicks off freeform canvas generation as a dedicated task: the
// user describes what they want and the agent builds + publishes the canvas. No
Expand Down Expand Up @@ -48,24 +48,50 @@ export const FreeformGenerateBar = forwardRef<
templateId,
});

// On a FIRST build we seed the agent with a known-good starter scaffold by
// default (faster, more consistent than authoring from scratch). Uncheck to
// opt out and have the agent build from a blank canvas. Only meaningful on an
// empty canvas, so the toggle is hidden in edit mode.
const isEdit = !!currentCode?.trim();
const [useStarter, setUseStarter] = useState(true);

const run = async (text: string) => {
const instruction = text.trim();
if (!instruction) return;
const taskId = await generate({ instruction, currentCode });
const taskId = await generate({
instruction,
currentCode,
useStarter: !isEdit && useStarter,
});
if (taskId) onStarted?.(taskId);
};

return (
<PromptInput
ref={ref}
sessionId={sessionId}
editorHeight="large"
disabled={isStarting}
isLoading={isStarting}
enableCommands
enableBashMode={false}
hideDefaultToolbar
onSubmit={(text) => void run(text)}
/>
<div className="flex flex-col gap-1.5">
<PromptInput
ref={ref}
sessionId={sessionId}
editorHeight="large"
disabled={isStarting}
isLoading={isStarting}
enableCommands
enableBashMode={false}
hideDefaultToolbar
onSubmit={(text) => void run(text)}
/>
{!isEdit && (
<label className="flex cursor-pointer select-none items-center gap-1.5 self-start px-1 text-muted-foreground text-xs">
<input
type="checkbox"
className="cursor-pointer"
checked={useStarter}
disabled={isStarting}
onChange={(e) => setUseStarter(e.target.checked)}
/>
Start from scaffold (faster, more consistent — uncheck to build from
scratch)
</label>
)}
</div>
);
});
Loading
Loading