Skip to content

Commit 3c61527

Browse files
authored
feat(canvas): comment-mode annotation overlay with element and text targets
Canvas feedback was whole-document chat: describing the target in prose was the only way to point at something. Add a comment mode to freeform canvases: a host-authored overlay in the sandbox iframe captures element clicks and text selections as structured, LLM-locatable targets (stable-attribute selector, bounded text, attributes), queued annotations render as numbered pins in the canvas and editable comment chips above the composer, and on submit they fold into the generation instruction as a numbered [Annotations] block the agent resolves into targeted edits of the checked-out scratch file. Selector inference follows the toolbar's product-tours approach (stable attribute allowlist, nth-of-type fallback) in a dependency-free form — precision is secondary since the consumer is the agent locating spots in source it wrote itself. Generated-By: PostHog Code Task-Id: 2b3d176e-3023-41d2-92e8-81eda7c12a8c
1 parent 40df541 commit 3c61527

12 files changed

Lines changed: 662 additions & 20 deletions

packages/core/src/canvas/freeformSchemas.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,51 @@ export type CanvasAnalyticsConfig = z.infer<typeof canvasAnalyticsConfigSchema>;
164164
export const canvasThemeSchema = z.enum(["light", "dark"]);
165165
export type CanvasTheme = z.infer<typeof canvasThemeSchema>;
166166

167+
// ---------------------------------------------------------------------------
168+
// Annotation targets captured by the comment-mode overlay (structured canvas
169+
// feedback). Deliberately LLM-locatable rather than machine-precise: the
170+
// consumer is the generation agent locating the spot in source IT wrote, so
171+
// the bounded text + stable attributes matter more than selector exactness.
172+
// ---------------------------------------------------------------------------
173+
export const canvasElementTargetSchema = z.object({
174+
type: z.literal("element"),
175+
// Best-effort CSS selector for the rendered element (stable attributes
176+
// preferred, nth-of-type fallback).
177+
selector: z.string(),
178+
tag: z.string(),
179+
// Bounded innerText snippet — usually the strongest locator.
180+
text: z.string(),
181+
ariaLabel: z.string().nullable().optional(),
182+
// The stable attributes present on the element (data-attr, id, role, …).
183+
attributes: z.record(z.string(), z.string()),
184+
});
185+
export type CanvasElementTarget = z.infer<typeof canvasElementTargetSchema>;
186+
187+
export const canvasTextRangeTargetSchema = z.object({
188+
type: z.literal("text-range"),
189+
// The selected text, bounded.
190+
text: z.string(),
191+
ancestorSelector: z.string(),
192+
ancestorTag: z.string(),
193+
});
194+
export type CanvasTextRangeTarget = z.infer<typeof canvasTextRangeTargetSchema>;
195+
196+
export const canvasAnnotationTargetSchema = z.discriminatedUnion("type", [
197+
canvasElementTargetSchema,
198+
canvasTextRangeTargetSchema,
199+
]);
200+
export type CanvasAnnotationTarget = z.infer<
201+
typeof canvasAnnotationTargetSchema
202+
>;
203+
204+
// A queued annotation the host asks the overlay to render as a numbered pin.
205+
export const canvasAnnotationPinSchema = z.object({
206+
id: z.string(),
207+
n: z.number(),
208+
selector: z.string(),
209+
});
210+
export type CanvasAnnotationPin = z.infer<typeof canvasAnnotationPinSchema>;
211+
167212
// host -> iframe
168213
export const hostToCanvasMessageSchema = z.discriminatedUnion("type", [
169214
// First frame: hand the iframe its source + the run mode. The iframe does not
@@ -199,6 +244,19 @@ export const hostToCanvasMessageSchema = z.discriminatedUnion("type", [
199244
result: z.unknown().optional(),
200245
error: z.string().optional(),
201246
}),
247+
// Toggle the annotation ("comment mode") overlay: crosshair cursor, hover
248+
// highlight, and click-to-capture targets.
249+
z.object({
250+
channel: z.literal(CANVAS_CHANNEL),
251+
type: z.literal("set-annotation-mode"),
252+
enabled: z.boolean(),
253+
}),
254+
// The queued annotations to render as numbered pins over their targets.
255+
z.object({
256+
channel: z.literal(CANVAS_CHANNEL),
257+
type: z.literal("annotation-pins"),
258+
pins: z.array(canvasAnnotationPinSchema),
259+
}),
202260
]);
203261
export type HostToCanvasMessage = z.infer<typeof hostToCanvasMessageSchema>;
204262

@@ -254,5 +312,12 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [
254312
type: z.literal("navigate"),
255313
nav: canvasNavIntentSchema,
256314
}),
315+
// A comment-mode click captured an annotation target. The comment itself is
316+
// typed host-side; only the target crosses the boundary.
317+
z.object({
318+
channel: z.literal(CANVAS_CHANNEL),
319+
type: z.literal("annotation-target"),
320+
target: canvasAnnotationTargetSchema,
321+
}),
257322
]);
258323
export type CanvasToHostMessage = z.infer<typeof canvasToHostMessageSchema>;

packages/ui/src/features/canvas/freeform/CanvasFrameHost.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export function CanvasFrameHost() {
5656
onError={slot.inputs.onError}
5757
onRendered={slot.inputs.onRendered}
5858
onNavigate={slot.inputs.onNavigate}
59+
annotationMode={slot.inputs.annotationMode}
60+
annotationPins={slot.inputs.annotationPins}
61+
onAnnotationTarget={slot.inputs.onAnnotationTarget}
5962
/>
6063
</ErrorBoundary>
6164
</div>

packages/ui/src/features/canvas/freeform/CanvasFramePlaceholder.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type {
22
CanvasAnalyticsConfig,
3+
CanvasAnnotationPin,
4+
CanvasAnnotationTarget,
35
CanvasNavIntent,
46
} from "@posthog/core/canvas/freeformSchemas";
57
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
@@ -19,6 +21,9 @@ export function CanvasFramePlaceholder({
1921
onError,
2022
onRendered,
2123
onNavigate,
24+
annotationMode,
25+
annotationPins,
26+
onAnnotationTarget,
2227
}: {
2328
dashboardId: string;
2429
code: string;
@@ -27,6 +32,9 @@ export function CanvasFramePlaceholder({
2732
onError?: (message: string, stack?: string) => void;
2833
onRendered?: () => void;
2934
onNavigate?: (intent: CanvasNavIntent) => void;
35+
annotationMode?: boolean;
36+
annotationPins?: CanvasAnnotationPin[];
37+
onAnnotationTarget?: (target: CanvasAnnotationTarget) => void;
3038
}) {
3139
const ref = useRef<HTMLDivElement>(null);
3240
const refreshKey = useCanvasRefreshNonce(`dashboard:${dashboardId}`);
@@ -45,6 +53,9 @@ export function CanvasFramePlaceholder({
4553
onError,
4654
onRendered,
4755
onNavigate,
56+
annotationMode,
57+
annotationPins,
58+
onAnnotationTarget,
4859
}),
4960
[
5061
code,
@@ -54,6 +65,9 @@ export function CanvasFramePlaceholder({
5465
onError,
5566
onRendered,
5667
onNavigate,
68+
annotationMode,
69+
annotationPins,
70+
onAnnotationTarget,
5771
],
5872
);
5973

packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
22
type CanvasAnalyticsConfig,
3+
type CanvasAnnotationPin,
4+
type CanvasAnnotationTarget,
35
type CanvasNavIntent,
46
type CanvasToHostMessage,
57
canvasToHostMessageSchema,
@@ -51,6 +53,12 @@ export interface FreeformCanvasProps {
5153
* reloads the frame via the existing reload path.
5254
*/
5355
refreshKey?: number;
56+
/** Comment mode: the overlay captures element/text targets on click. */
57+
annotationMode?: boolean;
58+
/** Queued annotations rendered as numbered pins over their targets. */
59+
annotationPins?: CanvasAnnotationPin[];
60+
/** Called when the overlay captures a target (the comment is typed host-side). */
61+
onAnnotationTarget?: (target: CanvasAnnotationTarget) => void;
5462
}
5563

5664
// Renders a freeform-React canvas inside a null-origin sandboxed iframe and
@@ -65,6 +73,9 @@ export function FreeformCanvas({
6573
onNavigate,
6674
analytics,
6775
refreshKey = 0,
76+
annotationMode = false,
77+
annotationPins,
78+
onAnnotationTarget,
6879
}: FreeformCanvasProps) {
6980
const iframeRef = useRef<HTMLIFrameElement>(null);
7081
// The canvas mirrors the host's light/dark theme. Passed via `init` (not the
@@ -92,25 +103,32 @@ export function FreeformCanvas({
92103
onError,
93104
onRendered,
94105
onNavigate,
106+
onAnnotationTarget,
95107
code,
96108
mode,
97109
analytics,
98110
theme,
111+
annotationMode,
112+
annotationPins,
99113
});
100114
latest.current = {
101115
onDataRequest,
102116
onError,
103117
onRendered,
104118
onNavigate,
119+
onAnnotationTarget,
105120
code,
106121
mode,
107122
analytics,
108123
theme,
124+
annotationMode,
125+
annotationPins,
109126
};
110127

111128
const postInit = useCallback(() => {
112129
const p = latest.current;
113-
iframeRef.current?.contentWindow?.postMessage(
130+
const target = iframeRef.current?.contentWindow;
131+
target?.postMessage(
114132
{
115133
channel: "posthog-canvas",
116134
type: "init",
@@ -121,6 +139,24 @@ export function FreeformCanvas({
121139
},
122140
"*",
123141
);
142+
// A reload resets the overlay's state; re-sync comment mode + pins so they
143+
// survive srcDoc changes exactly like the theme survives via init.
144+
target?.postMessage(
145+
{
146+
channel: "posthog-canvas",
147+
type: "set-annotation-mode",
148+
enabled: p.annotationMode,
149+
},
150+
"*",
151+
);
152+
target?.postMessage(
153+
{
154+
channel: "posthog-canvas",
155+
type: "annotation-pins",
156+
pins: p.annotationPins ?? [],
157+
},
158+
"*",
159+
);
124160
}, []);
125161

126162
// The iframe reloads only when srcDoc changes (mode / analytics host); on
@@ -182,6 +218,9 @@ export function FreeformCanvas({
182218
// msg.nav is already allowlist-validated by safeParse below.
183219
latest.current.onNavigate?.(msg.nav);
184220
break;
221+
case "annotation-target":
222+
latest.current.onAnnotationTarget?.(msg.target);
223+
break;
185224
}
186225
};
187226

@@ -232,6 +271,30 @@ export function FreeformCanvas({
232271
);
233272
}, [theme]);
234273

274+
// Live comment-mode + pin updates (no remount; postInit re-syncs on reload).
275+
useEffect(() => {
276+
if (!readyRef.current) return;
277+
iframeRef.current?.contentWindow?.postMessage(
278+
{
279+
channel: "posthog-canvas",
280+
type: "set-annotation-mode",
281+
enabled: annotationMode,
282+
},
283+
"*",
284+
);
285+
}, [annotationMode]);
286+
useEffect(() => {
287+
if (!readyRef.current) return;
288+
iframeRef.current?.contentWindow?.postMessage(
289+
{
290+
channel: "posthog-canvas",
291+
type: "annotation-pins",
292+
pins: annotationPins ?? [],
293+
},
294+
"*",
295+
);
296+
}, [annotationPins]);
297+
235298
return (
236299
<iframe
237300
ref={iframeRef}

packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ import {
22
ArrowCounterClockwiseIcon,
33
ArrowUUpLeftIcon,
44
ArrowUUpRightIcon,
5+
CursorClickIcon,
56
ShapesIcon,
67
SidebarSimpleIcon,
78
SpinnerGapIcon,
89
WarningIcon,
910
} from "@phosphor-icons/react";
10-
import type { CanvasAnalyticsConfig } from "@posthog/core/canvas/freeformSchemas";
11+
import type {
12+
CanvasAnalyticsConfig,
13+
CanvasAnnotationTarget,
14+
} from "@posthog/core/canvas/freeformSchemas";
1115
import { useHostTRPC } from "@posthog/host-router/react";
1216
import {
1317
Button,
@@ -23,6 +27,10 @@ import {
2327
isCanvasGenerationRunning,
2428
} from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus";
2529
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
30+
import {
31+
useCanvasAnnotations,
32+
useCanvasAnnotationsStore,
33+
} from "@posthog/ui/features/canvas/stores/canvasAnnotationsStore";
2634
import { useCanvasChatPanelStore } from "@posthog/ui/features/canvas/stores/canvasChatPanelStore";
2735
import {
2836
useFreeformChatStore,
@@ -216,6 +224,32 @@ export function FreeformCanvasView({
216224
// Routes the canvas's allowlisted nav intents within this channel.
217225
const onNavigate = useCanvasNavigation(channelId);
218226

227+
// Comment mode: the overlay captures element/text targets; each becomes a
228+
// queued annotation (chip + pin) that rides the next edit instruction.
229+
const [annotationMode, setAnnotationMode] = useState(false);
230+
const annotations = useCanvasAnnotations(dashboardId);
231+
const addAnnotation = useCanvasAnnotationsStore((s) => s.add);
232+
const onAnnotationTarget = useCallback(
233+
(target: CanvasAnnotationTarget) => {
234+
addAnnotation(dashboardId, target);
235+
// Reveal the composer so the comment can be typed right away.
236+
setCollapsed(false);
237+
},
238+
[addAnnotation, dashboardId, setCollapsed],
239+
);
240+
const annotationPins = useMemo(
241+
() =>
242+
annotations.map((a, i) => ({
243+
id: a.id,
244+
n: i + 1,
245+
selector:
246+
a.target.type === "element"
247+
? a.target.selector
248+
: a.target.ancestorSelector,
249+
})),
250+
[annotations],
251+
);
252+
219253
// The edit composer's editor handle, so self-repair can prefill it.
220254
const editorRef = useRef<EditorHandle>(null);
221255
const askAgentToFix = () => {
@@ -309,6 +343,26 @@ export function FreeformCanvasView({
309343
{isResetting ? "Resetting…" : "Reset to default"}
310344
</Button>
311345
)}
346+
{showCanvas && (
347+
<Tooltip
348+
content={
349+
annotationMode
350+
? "Exit comment mode"
351+
: "Comment on elements — click anything in the canvas"
352+
}
353+
>
354+
<Button
355+
size="icon"
356+
variant={annotationMode ? "primary" : "default"}
357+
aria-label="Comment mode"
358+
className="ml-1"
359+
disabled={isGenerating}
360+
onClick={() => setAnnotationMode((v) => !v)}
361+
>
362+
<CursorClickIcon size={16} />
363+
</Button>
364+
</Tooltip>
365+
)}
312366
</Flex>
313367
<Flex align="center" gap="2">
314368
{isGenerating && effectiveTaskId ? (
@@ -383,6 +437,9 @@ export function FreeformCanvasView({
383437
onError={onError}
384438
onRendered={onRendered}
385439
onNavigate={onNavigate}
440+
annotationMode={annotationMode}
441+
annotationPins={annotationPins}
442+
onAnnotationTarget={onAnnotationTarget}
386443
/>
387444
</Box>
388445
) : (

0 commit comments

Comments
 (0)