Skip to content

Commit df1c8cb

Browse files
k11kirkyadamleithpclaude
authored
feat(canvas): right-side chat/edit panel for freeform canvas (#2933)
Co-authored-by: Adam Leith <adamleithp@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 32e9d89 commit df1c8cb

12 files changed

Lines changed: 592 additions & 200 deletions

File tree

packages/ui/src/features/canvas/components/ChannelsList.tsx

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,8 @@ import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTask
5858
import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask";
5959
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
6060
import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
61-
import {
62-
NewCanvasDialog,
63-
trackAndCreateCanvas,
64-
} from "@posthog/ui/features/canvas/components/NewCanvasMenu";
61+
import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/components/NewCanvasMenu";
6562
import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal";
66-
import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates";
6763
import {
6864
useChannelStars,
6965
useChannelStarToggle,
@@ -976,11 +972,9 @@ function ChannelSection({
976972
const [open, setOpen] = useState(isActive);
977973
// Lifted so the hover button group stays visible while the menu is open.
978974
const [menuOpen, setMenuOpen] = useState(false);
979-
// The "+" dropdown (New task / New canvas) and the canvas template picker it
980-
// can open. Both keep the hover actions pinned while active.
975+
// The "+" dropdown (New task / New canvas). Keeps the hover actions pinned
976+
// while open.
981977
const [newMenuOpen, setNewMenuOpen] = useState(false);
982-
const [canvasOpen, setCanvasOpen] = useState(false);
983-
const canvasTemplates = useCanvasTemplates();
984978
const createAndOpenCanvas = useCreateAndOpenDashboard(channel.id);
985979
// Shared by the "..." dropdown and the right-click context menu so both offer
986980
// the same star / edit / rename / delete actions.
@@ -1144,7 +1138,7 @@ function ChannelSection({
11441138
aria-label={`New in ${channel.name}`}
11451139
className={cn(
11461140
"gap-1 transition-opacity group-hover:border-border",
1147-
menuOpen || newMenuOpen || canvasOpen
1141+
menuOpen || newMenuOpen
11481142
? "opacity-100"
11491143
: "opacity-0 group-hover/chan:opacity-100",
11501144
)}
@@ -1181,19 +1175,14 @@ function ChannelSection({
11811175
</DropdownMenuItem>
11821176
<DropdownMenuItem
11831177
onClick={() => {
1184-
// No templates loaded yet: create with the default
1185-
// template, matching NewCanvasMenu's fallback. Otherwise
1186-
// open the template picker.
1187-
if (canvasTemplates.length === 0) {
1188-
trackAndCreateCanvas(
1189-
channel.id,
1190-
undefined,
1191-
"sidebar",
1192-
() => void createAndOpenCanvas(),
1193-
);
1194-
} else {
1195-
setCanvasOpen(true);
1196-
}
1178+
// Create + open a canvas with the default template directly;
1179+
// the canvas's own composer drives what gets built.
1180+
trackAndCreateCanvas(
1181+
channel.id,
1182+
undefined,
1183+
"sidebar",
1184+
() => void createAndOpenCanvas(),
1185+
);
11971186
}}
11981187
>
11991188
<ChartBarIcon size={14} />
@@ -1208,12 +1197,6 @@ function ChannelSection({
12081197
onOpenChange={setMenuOpen}
12091198
/>
12101199
</ButtonGroup>
1211-
<NewCanvasDialog
1212-
channelId={channel.id}
1213-
surface="sidebar"
1214-
open={canvasOpen}
1215-
onOpenChange={setCanvasOpen}
1216-
/>
12171200
</div>
12181201
{/* Children hang off a vertical guide line, like a tree. The folder
12191202
variant's own inset is removed so the guide line controls indent. */}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { ShapesIcon } from "@phosphor-icons/react";
2+
import { CANVAS_GENERATE_SUGGESTIONS } from "@posthog/ui/features/canvas/freeform/canvasGenerateSuggestions";
3+
import { FreeformGenerateBar } from "@posthog/ui/features/canvas/freeform/FreeformGenerateBar";
4+
import type { EditorHandle } from "@posthog/ui/features/message-editor/types";
5+
import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard";
6+
import { DotPatternBackground } from "@posthog/ui/primitives/DotPatternBackground";
7+
import { Flex, Text } from "@radix-ui/themes";
8+
import { useRef } from "react";
9+
10+
// The empty-canvas landing state: a centered composer with starter-prompt
11+
// suggestions below it. Once the user submits, the canvas record records a
12+
// generation task and FreeformCanvasView swaps this hero for the canvas +
13+
// side-panel layout — the composer "floats to the side".
14+
export function CanvasGenerateHero({
15+
dashboardId,
16+
channelId,
17+
channelName,
18+
name,
19+
templateId,
20+
onStarted,
21+
}: {
22+
dashboardId: string;
23+
channelId: string;
24+
channelName: string;
25+
name: string;
26+
templateId?: string;
27+
onStarted?: (taskId: string) => void;
28+
}) {
29+
// Lets a suggestion card drop its prompt straight into the editor.
30+
const editorRef = useRef<EditorHandle>(null);
31+
32+
return (
33+
<Flex
34+
direction="column"
35+
align="center"
36+
justify="center"
37+
className="relative h-full w-full overflow-y-auto px-4 py-10"
38+
>
39+
<DotPatternBackground className="h-full" />
40+
<Flex direction="column" gap="5" className="z-[1] w-full max-w-[620px]">
41+
<Flex direction="column" align="center" gap="2" className="text-center">
42+
<Flex
43+
align="center"
44+
justify="center"
45+
className="size-10 rounded-xl bg-accent-3 text-accent-9"
46+
>
47+
<ShapesIcon size={20} weight="duotone" />
48+
</Flex>
49+
<Text size="5" weight="bold" className="text-gray-12">
50+
Build a canvas
51+
</Text>
52+
<Text size="2" className="text-gray-10">
53+
Describe what you want and an agent builds it.
54+
</Text>
55+
</Flex>
56+
57+
<FreeformGenerateBar
58+
ref={editorRef}
59+
sessionId={`canvas:${dashboardId}`}
60+
dashboardId={dashboardId}
61+
channelId={channelId}
62+
channelName={channelName}
63+
name={name}
64+
templateId={templateId}
65+
onStarted={onStarted}
66+
/>
67+
68+
<Flex direction="column" gap="2">
69+
<Text size="1" weight="medium" className="px-1 text-gray-11">
70+
Suggestions
71+
</Text>
72+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
73+
{CANVAS_GENERATE_SUGGESTIONS.map((suggestion) => (
74+
<SuggestedPromptCard
75+
key={suggestion.label}
76+
suggestion={suggestion}
77+
onSelect={() => {
78+
editorRef.current?.setContent(suggestion.prompt);
79+
editorRef.current?.focus();
80+
}}
81+
/>
82+
))}
83+
</div>
84+
</Flex>
85+
</Flex>
86+
</Flex>
87+
);
88+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
ChatCircleIcon,
3+
PencilSimpleIcon,
4+
SidebarSimpleIcon,
5+
SpinnerGapIcon,
6+
} from "@phosphor-icons/react";
7+
import { Button } from "@posthog/quill";
8+
import { FreeformGenerateBar } from "@posthog/ui/features/canvas/freeform/FreeformGenerateBar";
9+
import type { EditorHandle } from "@posthog/ui/features/message-editor/types";
10+
import { EmbeddedSessionView } from "@posthog/ui/features/sessions/components/EmbeddedSessionView";
11+
import { taskDetailQuery } from "@posthog/ui/features/tasks/queries";
12+
import { Flex, Text, Tooltip } from "@radix-ui/themes";
13+
import { useQuery } from "@tanstack/react-query";
14+
import type { Ref } from "react";
15+
16+
// The canvas's right-hand dock. While a generation/edit run is in flight it
17+
// shows that run's live chat (steering/queue included); otherwise it shows the
18+
// edit composer for the next change. Header carries a minimize control that
19+
// collapses the panel to a thin rail (handled by the parent).
20+
export function CanvasSidePanel({
21+
effectiveTaskId,
22+
onMinimize,
23+
dashboardId,
24+
channelId,
25+
channelName,
26+
name,
27+
templateId,
28+
currentCode,
29+
editorRef,
30+
onStarted,
31+
}: {
32+
effectiveTaskId: string | null;
33+
onMinimize: () => void;
34+
dashboardId: string;
35+
channelId: string;
36+
channelName: string;
37+
name: string;
38+
templateId?: string;
39+
currentCode?: string;
40+
// Exposes the edit composer's editor so self-repair can prefill it.
41+
editorRef?: Ref<EditorHandle>;
42+
onStarted?: (taskId: string) => void;
43+
}) {
44+
const isChat = !!effectiveTaskId;
45+
46+
return (
47+
<Flex direction="column" className="h-full min-w-0 bg-gray-1">
48+
<Flex
49+
align="center"
50+
justify="between"
51+
className="h-10 shrink-0 items-center border-b bg-chrome px-3"
52+
>
53+
<Flex align="center" gap="2" className="min-w-0">
54+
{isChat ? (
55+
<ChatCircleIcon size={15} className="shrink-0 text-gray-10" />
56+
) : (
57+
<PencilSimpleIcon size={15} className="shrink-0 text-gray-10" />
58+
)}
59+
<Text size="2" weight="medium" className="truncate text-gray-12">
60+
{isChat ? "Chat" : "Edit canvas"}
61+
</Text>
62+
</Flex>
63+
<Tooltip content="Minimize panel">
64+
<Button
65+
size="icon"
66+
variant="default"
67+
aria-label="Minimize panel"
68+
onClick={onMinimize}
69+
>
70+
<SidebarSimpleIcon size={16} />
71+
</Button>
72+
</Tooltip>
73+
</Flex>
74+
75+
<div className="min-h-0 flex-1">
76+
{effectiveTaskId ? (
77+
<CanvasChatLoader taskId={effectiveTaskId} />
78+
) : (
79+
<Flex direction="column" className="h-full p-3">
80+
<FreeformGenerateBar
81+
ref={editorRef}
82+
sessionId={`canvas:${dashboardId}`}
83+
dashboardId={dashboardId}
84+
channelId={channelId}
85+
channelName={channelName}
86+
name={name}
87+
templateId={templateId}
88+
currentCode={currentCode}
89+
onStarted={onStarted}
90+
/>
91+
</Flex>
92+
)}
93+
</div>
94+
</Flex>
95+
);
96+
}
97+
98+
// Resolves the run's task (shared react-query cache, so this dedupes with the
99+
// canvas view's own poll) and renders its live chat once available.
100+
function CanvasChatLoader({ taskId }: { taskId: string }) {
101+
const { data: task } = useQuery(taskDetailQuery(taskId));
102+
103+
if (!task) {
104+
return (
105+
<Flex align="center" justify="center" className="h-full">
106+
<SpinnerGapIcon size={18} className="animate-spin text-gray-9" />
107+
</Flex>
108+
);
109+
}
110+
111+
return <EmbeddedSessionView task={task} />;
112+
}

0 commit comments

Comments
 (0)