Skip to content

Commit 2cb8493

Browse files
committed
feat: Implement chat-based confirmation flow and enhance canvas element handling
1 parent 7f1523b commit 2cb8493

9 files changed

Lines changed: 186 additions & 79 deletions

File tree

agent/orchestrator.jac

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ glob _lock = threading.Lock();
6161
glob _ws: Any = None;
6262
glob _connected: bool = False;
6363
glob _pending: dict[str, Any] = {};
64+
glob _pending_confirms: dict[str, Any] = {};
6465
glob _relay_loop: Any = None;
6566
glob _relay_ready: Any = threading.Event();
6667

@@ -196,21 +197,38 @@ def _send_command(action: str, data: dict | None = None, session_id: str = "", t
196197
}
197198
return res.get("data");
198199
}
200+
"""Resolve a pending confirmation via chat WebSocket (called from server.jac)."""
201+
def _resolve_confirm(confirm_id: str, result: str) -> None {
202+
entry: Any = None;
203+
with _lock {
204+
entry = _pending_confirms.pop(confirm_id, None);
205+
}
206+
if entry is not None {
207+
(evt, res) = entry;
208+
res["result"] = result;
209+
evt.set();
210+
}
211+
}
199212

200-
201-
# ── Confirmation flow ─────────────────────────────────────────────────────────
202-
"""Show confirm dialog and poll for result (max 30s)."""
203-
def _request_confirm(provider: CanvasToolProvider, message: str) -> bool {
213+
"""Send confirmation request via chat WebSocket and wait for response (max 60s)."""
214+
def _chat_confirm(ws: Any, event_loop: Any, session_id: str, message: str) -> str {
204215
confirm_id: str = str(uuid.uuid4());
205-
provider.call_tool("show_confirm", {"confirmId": confirm_id, "message": message});
206-
deadline: float = time.time() + 30.0;
207-
while time.time() < deadline {
208-
result: dict[str, Any] = json.loads(provider.call_tool("get_confirm_result", {"confirmId": confirm_id}));
209-
if result.get("result") == "ok" { return True; }
210-
if result.get("result") == "cancel" { return False; }
211-
time.sleep(0.5);
216+
evt: Any = threading.Event();
217+
res: dict = {};
218+
with _lock {
219+
_pending_confirms[confirm_id] = (evt, res);
220+
}
221+
_send_ws(ws, {"type": "confirm", "sessionId": session_id, "confirmId": confirm_id, "message": message}, event_loop);
222+
if not evt.wait(timeout=60.0) {
223+
with _lock {
224+
_pending_confirms.pop(confirm_id, None);
225+
}
226+
return "cancel";
227+
}
228+
with _lock {
229+
_pending_confirms.pop(confirm_id, None);
212230
}
213-
return False;
231+
return res.get("result", "cancel");
214232
}
215233

216234

@@ -390,28 +408,50 @@ walker AgentOrchestrator {
390408

391409
canvas_mode: str = "replace";
392410
existing_elements_json: str = "[]";
411+
existing_bounding_box: str = "{}";
393412

394413
if not canvas_empty {
395-
# Canvas non-empty: request confirmation
396-
confirmed: bool = _request_confirm(
397-
self._provider,
398-
"Replace canvas content with new diagram?"
414+
# Canvas non-empty: request confirmation via chat
415+
confirm_result: str = _chat_confirm(
416+
self.ws,
417+
self.event_loop,
418+
self.session_id,
419+
"Canvas is not empty. **Approve** to clear it and render the new diagram, or **Deny** to place it beside the existing content."
399420
);
400-
if not confirmed {
401-
log("compose_diagram: user declined to replace canvas");
402-
self._session_context.canvas_summary = "non-empty, user kept";
403-
_send_ws(self.ws, {"type": "message", "sessionId": self.session_id, "content": "Diagram creation cancelled. Canvas kept as-is."}, self.event_loop);
404-
_send_ws(self.ws, {"type": "done", "sessionId": self.session_id}, self.event_loop);
405-
return;
421+
if confirm_result == "ok" {
422+
log("compose_diagram: user approved replace");
423+
# canvas_mode stays "replace"
424+
} else {
425+
log("compose_diagram: user denied replace, switching to additive mode");
426+
canvas_mode = "additive";
406427
}
407-
log("compose_diagram: user confirmed replacement");
408428
}
409429

410430
if canvas_mode == "additive" {
411431
try {
412-
existing_elements_json = self._provider.call_tool("get_canvas_elements", {});
432+
elements_result: str = self._provider.call_tool("get_canvas_elements", {});
433+
# call_tool returns JSON string; parse and re-serialize to ensure proper format
434+
parsed_els: dict = json.loads(elements_result);
435+
# Extract just the elements array if it's wrapped in a dict
436+
if isinstance(parsed_els, dict) and "elements" in parsed_els {
437+
existing_elements_json = json.dumps(parsed_els["elements"]);
438+
} else {
439+
existing_elements_json = elements_result;
440+
}
441+
log(f"compose_diagram: fetched {len(parsed_els) if isinstance(parsed_els, list) else len(parsed_els.get('elements', []))} existing elements");
413442
} except Exception as e {
414443
log(f"compose_diagram: error fetching elements: {e}");
444+
existing_elements_json = "[]";
445+
}
446+
try {
447+
bb_result: str = self._provider.call_tool("get_bounding_box", {});
448+
# call_tool returns JSON string; verify it's valid JSON
449+
parsed_bb: dict = json.loads(bb_result);
450+
existing_bounding_box = json.dumps(parsed_bb);
451+
log(f"compose_diagram: bounding box = {existing_bounding_box}");
452+
} except Exception as e {
453+
log(f"compose_diagram: error fetching bounding box: {e}");
454+
existing_bounding_box = "{}";
415455
}
416456
}
417457

@@ -426,7 +466,8 @@ walker AgentOrchestrator {
426466
self._session_context.conversation_log,
427467
self._session_context.diagram_blueprint,
428468
canvas_mode,
429-
existing_elements_json
469+
existing_elements_json,
470+
existing_bounding_box
430471
);
431472
log(f"compose_diagram: response = {response[:80]}");
432473

agent/server.jac

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import threading;
1111

1212
import websockets;
1313
import from typing { Any }
14-
import from .orchestrator { _boot as _orchestrator_boot, _run_conversation }
14+
import from .orchestrator { _boot as _orchestrator_boot, _run_conversation, _resolve_confirm }
1515

1616
glob CHAT_PORT: int = int(os.environ.get("JASKETCH_CHAT_PORT", "9602"));
1717

@@ -33,6 +33,10 @@ async def _handler(ws: Any) -> None {
3333
async for raw in ws {
3434
try {
3535
m: dict[str, Any] = json.loads(raw);
36+
if m.get("type") == "confirm_response" {
37+
_resolve_confirm(m.get("confirmId", ""), m.get("result", "cancel"));
38+
continue;
39+
}
3640
if m.get("type") != "chat" { continue; }
3741
ak: str = m.get("apiKey", "");
3842
md: str = m.get("model", "gpt-4o-mini");

agent/subagents/synthesizer.jac

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ node DiagramComposer {
1515
has llm: Any = None;
1616

1717
def compose(message: str, conversation_log: str, diagram_blueprint: str,
18-
canvas_mode: str, existing_elements_json: str) -> str
18+
canvas_mode: str, existing_elements_json: str, existing_bounding_box: str) -> str
1919
by self.llm(
2020
tools=[use_canvas_tool],
2121
max_react_iterations=8,
@@ -24,7 +24,8 @@ node DiagramComposer {
2424
"conversation": conversation_log,
2525
"diagram_blueprint": diagram_blueprint,
2626
"canvas_mode": canvas_mode,
27-
"existing_elements_json": existing_elements_json
27+
"existing_elements_json": existing_elements_json,
28+
"existing_bounding_box": existing_bounding_box
2829
}
2930
);
3031
}
@@ -38,22 +39,51 @@ Inputs (from incl_info):
3839
- diagram_blueprint: JSON-serialized BlueprintSpec from DiagramPlanner (may be empty string)
3940
- conversation: rolling transcript of recent turns
4041
42+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43+
⚠️ CRITICAL: canvas_mode DETERMINES YOUR TOOL CHOICE ⚠️
44+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45+
46+
IF canvas_mode == "replace":
47+
→ MUST use render_elements (this clears and replaces)
48+
→ DO NOT use create_elements
49+
50+
IF canvas_mode == "additive":
51+
→ MUST use create_elements (this adds without clearing)
52+
→ DO NOT use render_elements
53+
→ Place new diagram beside existing using existing_bounding_box
54+
55+
If you use the wrong tool, the diagram will not be placed correctly.
56+
57+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4158
Workflow for replace mode:
59+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
60+
4261
1. Call use_canvas_tool("get_drawing_guide", "{}") to load element schemas and styling palette
4362
2. Call use_canvas_tool("get_viewport", "{}") to get canvas dimensions
4463
3. Follow diagram_blueprint if provided; otherwise infer structure from message
4564
4. Design the COMPLETE elements[] array (all shapes, arrows, text labels) using the styling guide
4665
5. Run the PRE-RENDER CHECKLIST below — fix any issues before rendering
47-
6. Call use_canvas_tool("render_elements", "{\"elements\": [...]}") with ALL elements
66+
6. Call use_canvas_tool("render_elements", "{\"elements\": [...]}")
67+
MUST include ALL elements (clears canvas first, then renders)
4868
7. Describe what was created
4969
70+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5071
Workflow for additive mode:
72+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
73+
5174
1. Call use_canvas_tool("get_drawing_guide", "{}") to load element schemas and styling palette
52-
2. Call use_canvas_tool("get_viewport", "{}") to get canvas dimensions and existing bounding box
53-
3. Parse existing_elements_json to understand current layout
54-
4. Position new elements beside/below existing ones (no overlap)
55-
5. Run the PRE-RENDER CHECKLIST below
56-
6. Call use_canvas_tool("create_elements", "{\"elements\": [...]}") to add without clearing
75+
2. Call use_canvas_tool("get_viewport", "{}") to get canvas dimensions
76+
3. Parse existing_elements_json to understand what's already on the canvas
77+
4. Read existing_bounding_box from incl_info — format: {x, y, width, height}
78+
- If bounding_box has x, y, width, height:
79+
Place new diagram starting at: x + width + 120 (120px gap to the right)
80+
- If bounding_box is empty/null, start at x=100, y=100
81+
5. Design NEW elements only (DON'T include existing elements)
82+
6. Run the PRE-RENDER CHECKLIST below
83+
7. Call use_canvas_tool("create_elements", "{\"elements\": [...]}")
84+
MUST use create_elements, NOT render_elements
85+
Only pass your NEW elements
86+
8. Describe what was added
5787
5888
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5989
ELEMENT SCHEMAS
@@ -195,9 +225,9 @@ def use_canvas_tool(name: str, arguments: str) -> str {
195225
} except Exception { args = {}; }
196226
return _composer_provider.call_tool(name, args);
197227
}
198-
sem use_canvas_tool = "Call a canvas tool by name. Only use tools listed in available_canvas_tools from context.";
199-
sem use_canvas_tool.name = "Tool name — must exactly match one of the names in available_canvas_tools.";
200-
sem use_canvas_tool.arguments = "JSON object string matching the inputSchema of the named tool. Build from the schema in available_canvas_tools.";
228+
sem use_canvas_tool = "Call a canvas tool by name with JSON arguments. Always pass both 'name' and 'arguments' parameters.";
229+
sem use_canvas_tool.name = "(required) Tool name — must exactly match one of: get_drawing_guide, get_viewport, render_elements, create_elements";
230+
sem use_canvas_tool.arguments = "(required) JSON object string (not Python dict) matching the inputSchema of the named tool. Example: '{\"elements\": [...]}' or '{}' for tools with no inputs.";
201231

202232
# ── Provider injection (called by orchestrator) ────────────────────────────────
203233

components/layout/ChatPanel.cl.jac

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,33 @@ def ChatMessage(props: dict) -> JsxElement {
170170
return None;
171171
}
172172

173+
def CanvasConfirmModal(props: dict) -> JsxElement {
174+
pending = props.pending;
175+
onApprove = props.onApprove;
176+
onDeny = props.onDeny;
177+
if not pending or not pending.confirmId {
178+
return <></>;
179+
}
180+
return <div className="flex-shrink-0 border-t border-amber-200/40 bg-amber-50 px-4 py-3">
181+
<div className="flex items-start gap-3">
182+
<div className="flex-1 min-w-0">
183+
<p className="text-xs font-semibold text-amber-600 mb-1">Canvas Confirmation</p>
184+
<p className="text-xs text-gray-700 mb-3">{pending.message}</p>
185+
<div className="flex gap-2">
186+
<button onClick={onApprove}
187+
className="bg-green-100/60 hover:bg-green-100 text-green-700 border border-green-200 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors cursor-pointer">
188+
Approve
189+
</button>
190+
<button onClick={onDeny}
191+
className="bg-red-100/60 hover:bg-red-100 text-red-700 border border-red-200 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors cursor-pointer">
192+
Deny
193+
</button>
194+
</div>
195+
</div>
196+
</div>
197+
</div>;
198+
}
199+
173200
def ChatInput(props: dict) -> JsxElement {
174201
canSend = props.apiKey and props.inputText.trim() and not props.isLoading;
175202
isDisabled = not props.apiKey or props.isLoading;
@@ -333,6 +360,12 @@ def:pub ChatPanel(props: dict) -> JsxElement {
333360
<div ref={messagesEndRef}></div>
334361
</div>
335362
363+
<CanvasConfirmModal
364+
pending={chat.pendingConfirm}
365+
onApprove={lambda -> None { chat.respondConfirm("ok"); }}
366+
onDeny={lambda -> None { chat.respondConfirm("deny"); }}
367+
/>
368+
336369
<ChatInput
337370
apiKey={apiKey}
338371
inputText={inputText}

hooks/useChatSocket.cl.jac

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ def:pub useChatSocket() -> dict {
44
has messages: list = [];
55
has isLoading: bool = False;
66
has isConnected: bool = False;
7+
has pendingConfirm: dict = {};
78

89
wsRef = useRef(None);
910
connectedRef = useRef(False);
@@ -12,9 +13,11 @@ def:pub useChatSocket() -> dict {
1213
messagesRef = useRef([]);
1314
loadingRef = useRef(False);
1415
currentAssistantRef = useRef("");
16+
pendingConfirmRef = useRef({});
1517

1618
messagesRef.current = messages;
1719
loadingRef.current = isLoading;
20+
pendingConfirmRef.current = pendingConfirm;
1821

1922
def nextId() -> int {
2023
messageIdRef.current = messageIdRef.current + 1;
@@ -37,7 +40,10 @@ def:pub useChatSocket() -> dict {
3740
def handleWsMessage(event: any) -> None {
3841
data = JSON.parse(event.data);
3942

40-
if data.type == "message" {
43+
if data.type == "confirm" {
44+
pendingConfirm = {"confirmId": data.confirmId or "", "message": data.message or ""};
45+
pendingConfirmRef.current = pendingConfirm;
46+
} elif data.type == "message" {
4147
msgContent = data["content"] or "";
4248
messagesRef.current = messagesRef.current.concat([{"id": nextId(), "role": "assistant", "content": msgContent}]);
4349
messages = messagesRef.current;
@@ -154,6 +160,19 @@ def:pub useChatSocket() -> dict {
154160
messageIdRef.current = 0;
155161
}
156162

163+
def respondConfirm(result: str) -> None {
164+
cid = pendingConfirmRef.current.confirmId or "";
165+
if wsRef.current and connectedRef.current and cid {
166+
wsRef.current.send(JSON.stringify({
167+
"type": "confirm_response",
168+
"confirmId": cid,
169+
"result": result
170+
}));
171+
}
172+
pendingConfirm = {};
173+
pendingConfirmRef.current = {};
174+
}
175+
157176
can with entry {
158177
connect();
159178

@@ -172,6 +191,8 @@ def:pub useChatSocket() -> dict {
172191
"isLoading": isLoading,
173192
"isConnected": isConnected,
174193
"sendMessage": sendMessage,
175-
"clearMessages": clearMessages
194+
"clearMessages": clearMessages,
195+
"pendingConfirm": pendingConfirm,
196+
"respondConfirm": respondConfirm
176197
};
177198
}

hooks/useElements.cl.jac

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,19 @@ def:pub useElements() -> dict {
175175
setElementsWithHistory([]);
176176
}
177177

178+
def setElementsReplaceAll(newElements: list) -> None {
179+
# Add IDs to any elements missing them
180+
i = 0;
181+
while i < newElements.length {
182+
if not newElements[i].id {
183+
newElements[i].id = crypto.randomUUID();
184+
}
185+
i = i + 1;
186+
}
187+
# Replace all elements and push to history
188+
setElementsWithHistory(newElements);
189+
}
190+
178191
def undo() -> None {
179192
idx = historyIndexRef.current;
180193
if idx > 0 {
@@ -209,6 +222,7 @@ def:pub useElements() -> dict {
209222
"removeElement": removeElement,
210223
"removeMultipleElements": removeMultipleElements,
211224
"clearElements": clearElements,
225+
"setElementsReplaceAll": setElementsReplaceAll,
212226
"undo": undo,
213227
"redo": redo
214228
};

0 commit comments

Comments
 (0)