Skip to content

Commit 90c9e6f

Browse files
committed
Add agentic assistant panel to the image editor
Bring the timeline/storyboard editor's agent-chat pattern to the image (sketch) editor: a toggleable right-side assistant panel whose LLM can drive the canvas through a set of ui_sketch_* frontend tools. - sketchAgentBridge: serializable handler interface + snapshot types, with get/set/has, mirroring timelineAgentBridge. - useSketchAgentBridge: builds the handler from the editor's per-instance stores (editor, session bindings, canvas refs) plus the direct-gen job runner, and registers it while the editor is the focused surface. - ui_sketch_* tools (lib/tools/builtin/sketch.ts): get_state, add/remove/ duplicate/select layer, set_layer_props, reorder, merge_down, flatten_visible, generate (text-to-image / image-to-image), set_color, set_tool, resize_canvas, selection, and get_layer_image (vision). Wired into the frontend-tools IPC manifest. - SketchAgentPanel: reuses ChatView + GlobalChatStore, passing the open document id as workflow_id so the server forwards the ui_sketch_* tools. - Mounted as a toggleable column in SketchEditor with an Assistant toggle in the prompt bar; assistantPanelOpen flag added to the ui slice. - Extend SketchCanvasRefStore with getLayerData / fillLayerWithColor so the bridge can read and fill layers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyF6AfWZPdihy1TLKLi9ca
1 parent 50060c3 commit 90c9e6f

12 files changed

Lines changed: 1424 additions & 6 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/** @jsxImportSource @emotion/react */
2+
import { memo, useCallback, useEffect, useMemo } from "react";
3+
import { css } from "@emotion/react";
4+
import { useTheme } from "@mui/material/styles";
5+
import type { Theme } from "@mui/material/styles";
6+
import { useShallow } from "zustand/react/shallow";
7+
8+
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
9+
10+
import { FlexColumn, Text, SPACING, getSpacingPx } from "../ui_primitives";
11+
import ChatView from "../chat/containers/ChatView";
12+
import ChatPanelHeader from "../chat/containers/ChatPanelHeader";
13+
import useGlobalChatStore from "../../stores/GlobalChatStore";
14+
import { useSketchSessionStore } from "../../stores/sketch/SketchSessionStore";
15+
16+
const styles = (_theme: Theme) =>
17+
css({
18+
"&": {
19+
height: "100%",
20+
minHeight: 0,
21+
display: "flex",
22+
flexDirection: "column"
23+
},
24+
// ChatView is tuned for the full-page global chat (large left/bottom
25+
// padding, centered max-width). Tighten it for this narrow side panel.
26+
"& .chat-view": {
27+
padding: `0 ${getSpacingPx(SPACING.xs)} ${getSpacingPx(
28+
SPACING.xs
29+
)} ${getSpacingPx(SPACING.xs)}`
30+
},
31+
"& .chat-input-section": {
32+
width: "100%",
33+
maxWidth: "100%"
34+
},
35+
"& .chat-thread-container": {
36+
maxWidth: "100%",
37+
paddingBottom: getSpacingPx(SPACING.md)
38+
}
39+
});
40+
41+
/**
42+
* Chat surface for the image / sketch editor. Reuses {@link ChatView} wired to
43+
* the shared {@link useGlobalChatStore}, so the assistant can call the
44+
* `ui_sketch_*` frontend tools the editor registers on the sketch agent
45+
* bridge — adding layers, generating imagery, and reshaping the canvas like a
46+
* real editor.
47+
*/
48+
const SketchAgentPanel = () => {
49+
const theme = useTheme();
50+
const cssStyles = useMemo(() => styles(theme), [theme]);
51+
52+
// Bind the open document as the chat's `workflow_id`. The server only
53+
// forwards client `ui_*` tools to the model when a turn carries a
54+
// workflow_id (unified-websocket-runner gates on it), so without this the
55+
// assistant never sees the editor's ui_sketch_* tools. The id is editor
56+
// context, not a routing signal — we don't set `workflow_target`, so the turn
57+
// stays a normal chat turn and the document is never run as a workflow.
58+
const documentId = useSketchSessionStore((s) => s.documentId);
59+
60+
const { status, statusMessage, progress } = useGlobalChatStore(
61+
useShallow((state) => ({
62+
status: state.status,
63+
statusMessage: state.statusMessage,
64+
progress: state.progress
65+
}))
66+
);
67+
68+
const { selectedModel, setSelectedModel } = useGlobalChatStore(
69+
useShallow((state) => ({
70+
selectedModel: state.selectedModel,
71+
setSelectedModel: state.setSelectedModel
72+
}))
73+
);
74+
75+
const { sendMessage, stopGeneration, connect, createNewThread, switchThread } =
76+
useGlobalChatStore(
77+
useShallow((state) => ({
78+
sendMessage: state.sendMessage,
79+
stopGeneration: state.stopGeneration,
80+
connect: state.connect,
81+
createNewThread: state.createNewThread,
82+
switchThread: state.switchThread
83+
}))
84+
);
85+
86+
// Subscribe to the cache + current thread so the panel re-renders as the
87+
// active conversation streams in; the messages themselves are read synchronously.
88+
const { currentThreadId, messageCache, getCurrentMessagesSync } =
89+
useGlobalChatStore(
90+
useShallow((state) => ({
91+
currentThreadId: state.currentThreadId,
92+
messageCache: state.messageCache,
93+
getCurrentMessagesSync: state.getCurrentMessagesSync
94+
}))
95+
);
96+
const messages = useMemo(
97+
() => getCurrentMessagesSync(),
98+
[getCurrentMessagesSync, currentThreadId, messageCache]
99+
);
100+
101+
// Establish the chat connection (and send the frontend-tool manifest, which
102+
// now includes the editor's ui_sketch_* tools) when the panel mounts.
103+
useEffect(() => {
104+
connect().catch((err) => {
105+
console.error("Failed to connect image editor chat:", err);
106+
});
107+
}, [connect]);
108+
109+
const chatStatus = useMemo(
110+
() => (status === "stopping" ? "loading" : status),
111+
[status]
112+
);
113+
114+
const handleNewChat = useCallback(async () => {
115+
try {
116+
const id = await createNewThread();
117+
switchThread(id);
118+
} catch (err) {
119+
console.error("Failed to start new image editor chat:", err);
120+
}
121+
}, [createNewThread, switchThread]);
122+
123+
const welcomePlaceholder = useMemo(
124+
() => (
125+
<FlexColumn
126+
align="center"
127+
justify="center"
128+
fullHeight
129+
padding={3}
130+
sx={{ textAlign: "center" }}
131+
>
132+
<AutoAwesomeIcon sx={{ fontSize: 40, mb: 1.5, opacity: 0.5 }} />
133+
<Text size="normal" weight={600} sx={{ mb: 1 }}>
134+
Editor Assistant
135+
</Text>
136+
<Text size="small" color="secondary" sx={{ maxWidth: 280 }}>
137+
Ask me to edit the image — e.g. &quot;generate a mountain landscape on
138+
a new layer&quot;, &quot;add a blank layer filled with black&quot;, or
139+
&quot;set the background layer to 50% opacity&quot;.
140+
</Text>
141+
</FlexColumn>
142+
),
143+
[]
144+
);
145+
146+
return (
147+
<div css={cssStyles}>
148+
<ChatPanelHeader onNewChat={handleNewChat} />
149+
<div style={{ flex: 1, minHeight: 0 }}>
150+
<ChatView
151+
status={chatStatus}
152+
messages={messages}
153+
workflowId={documentId}
154+
sendMessage={sendMessage}
155+
progress={progress.current}
156+
total={progress.total}
157+
progressMessage={statusMessage}
158+
model={selectedModel}
159+
onModelChange={setSelectedModel}
160+
onStop={stopGeneration}
161+
onNewChat={handleNewChat}
162+
requireToolSupport
163+
hideModePicker
164+
noMessagesPlaceholder={welcomePlaceholder}
165+
/>
166+
</div>
167+
</div>
168+
);
169+
};
170+
171+
export default memo(SketchAgentPanel);

web/src/components/sketch/SketchEditor.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ import {
6969
SketchCanvasPane
7070
} from "./editor-shell";
7171
import { ConnectedGeneratedLayerSection } from "./Inspector";
72+
import SketchAgentPanel from "./SketchAgentPanel";
73+
import { useSketchAgentBridge } from "../../hooks/sketch/useSketchAgentBridge";
7274
import { useSketchCanvasRefStore } from "../../stores/sketch/SketchCanvasRefStore";
7375
import { useSketchSessionStore } from "../../stores/sketch/SketchSessionStore";
7476
import { useSketchWorkflowFreshnessCheck } from "../../hooks/sketch/useSketchWorkflowFreshnessCheck";
@@ -182,6 +184,12 @@ export interface SketchEditorProps {
182184
/** Document-level actions rendered at the trailing edge of the top mode bar
183185
* (e.g. Save/Done when embedded in an asset tab). */
184186
headerActions?: React.ReactNode;
187+
/**
188+
* Whether this editor is the focused/visible surface. Drives whether this
189+
* instance registers the agent bridge so the `ui_sketch_*` tools target the
190+
* focused document. Defaults to `true`.
191+
*/
192+
active?: boolean;
185193
}
186194

187195
const SketchEditor = forwardRef<SketchEditorHandle, SketchEditorProps>(
@@ -194,7 +202,8 @@ const SketchEditor = forwardRef<SketchEditorHandle, SketchEditorProps>(
194202
onExportImage,
195203
onExportMask,
196204
suspendKeyboardShortcuts,
197-
headerActions
205+
headerActions,
206+
active = true
198207
},
199208
ref
200209
) {
@@ -205,6 +214,11 @@ const SketchEditor = forwardRef<SketchEditorHandle, SketchEditorProps>(
205214
// every child so the column's reserved width also collapses, letting
206215
// the canvas grow into the freed space.
207216
const panelsHidden = useSketchStore((s) => s.panelsHidden);
217+
const assistantPanelOpen = useSketchStore((s) => s.assistantPanelOpen);
218+
219+
// Register the agent bridge for this instance while it is the focused
220+
// surface so the `ui_sketch_*` tools drive this document.
221+
useSketchAgentBridge(active);
208222

209223
// ─── Session layer (all transient editor-session state) ─────────────
210224
const session = useEditorSession({
@@ -246,6 +260,10 @@ const SketchEditor = forwardRef<SketchEditorHandle, SketchEditorProps>(
246260
getMaskDataUrl: () => canvasRef.current?.getMaskDataUrl() ?? null,
247261
setLayerData: (layerId, data) =>
248262
canvasRef.current?.setLayerData(layerId, data),
263+
getLayerData: (layerId) =>
264+
canvasRef.current?.getLayerData(layerId) ?? null,
265+
fillLayerWithColor: (layerId, color) =>
266+
canvasRef.current?.fillLayerWithColor(layerId, color),
249267
clearActiveLayer: () => session.canvasActions.handleClearLayer()
250268
});
251269
return () => {
@@ -531,6 +549,28 @@ const SketchEditor = forwardRef<SketchEditorHandle, SketchEditorProps>(
531549
<ConnectedGeneratedLayerSection />
532550
</FlexColumn>
533551
)}
552+
553+
{/* AI assistant chat column — a toggleable right-most panel that
554+
drives the editor through the ui_sketch_* agent tools. Gated on the
555+
same panelsHidden chrome toggle so Tab collapses it too. */}
556+
{!panelsHidden && assistantPanelOpen && (
557+
<FlexColumn
558+
className="sketch-editor__assistant-panel"
559+
sx={{
560+
width: SKETCH_SIZE.assistantPanelWidth,
561+
minWidth: SKETCH_SIZE.assistantPanelWidth,
562+
maxWidth: SKETCH_SIZE.assistantPanelWidth,
563+
minHeight: 0,
564+
flexShrink: 0,
565+
backgroundColor: theme.vars.palette.background.paper,
566+
borderLeft: `1px solid ${theme.vars.palette.divider}`,
567+
overflow: "hidden"
568+
}}
569+
gap={0}
570+
>
571+
<SketchAgentPanel />
572+
</FlexColumn>
573+
)}
534574
</FlexRow>
535575

536576
{/* Full-width status bar — standalone editor only (gates internally). */}

web/src/components/sketch/StandaloneSketchEditor.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,8 @@ interface StandaloneSketchEditorProps {
6868
active?: boolean;
6969
}
7070

71-
const StandaloneSketchEditorBody: React.FC<
72-
Omit<StandaloneSketchEditorProps, "active">
73-
> = memo(
74-
function StandaloneSketchEditorBody({ documentId, headerActions }) {
71+
const StandaloneSketchEditorBody: React.FC<StandaloneSketchEditorProps> = memo(
72+
function StandaloneSketchEditorBody({ documentId, headerActions, active }) {
7573
const theme = useTheme();
7674
const styles = useMemo(() => containerStyles(theme), [theme]);
7775
const editorRef = useRef<SketchEditorHandle | null>(null);
@@ -213,6 +211,7 @@ const StandaloneSketchEditorBody: React.FC<
213211
<SketchEditor
214212
ref={editorRef}
215213
documentId={documentId}
214+
active={active}
216215
initialDocument={seed.document}
217216
initialEditorState={initialEditorState ?? undefined}
218217
headerActions={
@@ -250,7 +249,7 @@ const StandaloneSketchEditor: React.FC<StandaloneSketchEditorProps> = ({
250249
...bodyProps
251250
}) => (
252251
<SketchProvider active={active}>
253-
<StandaloneSketchEditorBody {...bodyProps} />
252+
<StandaloneSketchEditorBody active={active} {...bodyProps} />
254253
</SketchProvider>
255254
);
256255

web/src/components/sketch/editor-shell/ConnectedModePromptBar.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ const ConnectedModePromptBarInner: React.FC<ConnectedModePromptBarProps> = ({
8383
const theme = useTheme();
8484

8585
const panelsHidden = useSketchStore((s) => s.panelsHidden);
86+
const assistantPanelOpen = useSketchStore((s) => s.assistantPanelOpen);
87+
const toggleAssistantPanel = useSketchStore((s) => s.toggleAssistantPanel);
8688
const docW = useSketchStore((s) => s.document.canvas.width);
8789
const docH = useSketchStore((s) => s.document.canvas.height);
8890

@@ -357,6 +359,20 @@ const ConnectedModePromptBarInner: React.FC<ConnectedModePromptBarProps> = ({
357359
Generate
358360
</EditorButton>
359361

362+
{/* Assistant toggle — opens the right-side AI chat panel that drives
363+
the editor through the ui_sketch_* agent tools. */}
364+
<EditorButton
365+
variant={assistantPanelOpen ? "contained" : "outlined"}
366+
size="small"
367+
onClick={toggleAssistantPanel}
368+
startIcon={<AutoAwesomeIcon fontSize="small" />}
369+
aria-pressed={assistantPanelOpen}
370+
data-testid="sketch-assistant-toggle"
371+
sx={{ flexShrink: 0, height: 34 }}
372+
>
373+
Assistant
374+
</EditorButton>
375+
360376
{trailingActions}
361377
</FlexRow>
362378

0 commit comments

Comments
 (0)