cowork-dash 0.1.9__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cowork_dash/app.py CHANGED
@@ -35,7 +35,7 @@ from .canvas import export_canvas_to_markdown, load_canvas_from_markdown
35
35
  from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
36
36
  from .components import (
37
37
  format_message, format_loading, format_thinking, format_todos_inline, render_canvas_items, format_tool_calls_inline,
38
- format_interrupt
38
+ format_interrupt, extract_display_inline_results, render_display_inline_result, extract_thinking_from_tool_calls
39
39
  )
40
40
  from .layout import create_layout as create_layout_component
41
41
  from .virtual_fs import get_session_manager
@@ -251,6 +251,7 @@ _agent_state = {
251
251
  "thinking": "",
252
252
  "todos": [],
253
253
  "tool_calls": [], # Current turn's tool calls (reset each turn)
254
+ "display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
254
255
  "canvas": load_canvas_from_markdown(WORKSPACE_ROOT) if not USE_VIRTUAL_FS else [], # Load from canvas.md if exists (physical FS only)
255
256
  "response": "",
256
257
  "error": None,
@@ -258,6 +259,7 @@ _agent_state = {
258
259
  "last_update": time.time(),
259
260
  "start_time": None, # Track when agent started for response time calculation
260
261
  "stop_requested": False, # Flag to request agent stop
262
+ "stop_event": None, # Threading event for immediate stop signaling
261
263
  }
262
264
  _agent_state_lock = threading.Lock()
263
265
 
@@ -275,11 +277,13 @@ def _get_default_agent_state() -> Dict[str, Any]:
275
277
  "thinking": "",
276
278
  "todos": [],
277
279
  "tool_calls": [],
280
+ "display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
278
281
  "canvas": [],
279
282
  "response": "",
280
283
  "error": None,
281
284
  "interrupt": None,
282
285
  "last_update": time.time(),
286
+ "stop_event": None, # Threading event for immediate stop signaling
283
287
  "start_time": None,
284
288
  "stop_requested": False,
285
289
  }
@@ -323,7 +327,9 @@ def _get_session_state_lock() -> threading.Lock:
323
327
 
324
328
 
325
329
  def request_agent_stop(session_id: Optional[str] = None):
326
- """Request the agent to stop execution.
330
+ """Request the agent to stop execution immediately.
331
+
332
+ Sets the stop_requested flag and signals the stop_event for immediate interruption.
327
333
 
328
334
  Args:
329
335
  session_id: Session ID for virtual FS mode, None for physical FS mode.
@@ -333,10 +339,16 @@ def request_agent_stop(session_id: Optional[str] = None):
333
339
  with _session_agents_lock:
334
340
  state["stop_requested"] = True
335
341
  state["last_update"] = time.time()
342
+ # Signal the stop event for immediate interruption
343
+ if state.get("stop_event"):
344
+ state["stop_event"].set()
336
345
  else:
337
346
  with _agent_state_lock:
338
347
  _agent_state["stop_requested"] = True
339
348
  _agent_state["last_update"] = time.time()
349
+ # Signal the stop event for immediate interruption
350
+ if _agent_state.get("stop_event"):
351
+ _agent_state["stop_event"].set()
340
352
 
341
353
 
342
354
  def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None, session_id: Optional[str] = None):
@@ -367,6 +379,12 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
367
379
  current_state["running"] = False
368
380
  return
369
381
 
382
+ # Create a stop event for immediate interruption
383
+ stop_event = threading.Event()
384
+ with state_lock:
385
+ current_state["stop_event"] = stop_event
386
+ current_state["stop_requested"] = False # Reset stop flag
387
+
370
388
  # Track tool calls by their ID for updating status
371
389
  tool_call_map = {}
372
390
 
@@ -413,6 +431,17 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
413
431
  # Resume from interrupt
414
432
  from langgraph.types import Command
415
433
  agent_input = Command(resume=resume_data)
434
+
435
+ # Rebuild tool_call_map from existing tool calls and mark pending ones as running
436
+ with state_lock:
437
+ for tc in current_state.get("tool_calls", []):
438
+ tc_id = tc.get("id")
439
+ if tc_id:
440
+ tool_call_map[tc_id] = tc
441
+ # Mark pending tool calls back to running since we're resuming
442
+ if tc.get("status") == "pending":
443
+ tc["status"] = "running"
444
+ current_state["last_update"] = time.time()
416
445
  else:
417
446
  # Inject workspace context into the message if available
418
447
  if workspace_path:
@@ -423,14 +452,15 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
423
452
  agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
424
453
 
425
454
  for update in current_agent.stream(agent_input, stream_mode="updates", config=stream_config):
426
- # Check if stop was requested
427
- with state_lock:
428
- if current_state.get("stop_requested"):
429
- current_state["response"] = current_state.get("response", "") + "\n\nAgent stopped by user."
455
+ # Check if stop was requested (via flag or event)
456
+ if stop_event.is_set() or current_state.get("stop_requested"):
457
+ with state_lock:
458
+ current_state["response"] = current_state.get("response", "") + "\n\n⏹️ Agent stopped by user."
430
459
  current_state["running"] = False
431
460
  current_state["stop_requested"] = False
461
+ current_state["stop_event"] = None
432
462
  current_state["last_update"] = time.time()
433
- return
463
+ return
434
464
 
435
465
  # Check for interrupt
436
466
  if isinstance(update, dict) and "__interrupt__" in update:
@@ -439,6 +469,10 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
439
469
  with state_lock:
440
470
  current_state["interrupt"] = interrupt_data
441
471
  current_state["running"] = False # Pause until user responds
472
+ # Mark any "running" tool calls as "pending" since we're waiting for user approval
473
+ for tc in current_state["tool_calls"]:
474
+ if tc.get("status") == "running":
475
+ tc["status"] = "pending"
442
476
  current_state["last_update"] = time.time()
443
477
  return # Exit stream, wait for user to resume
444
478
 
@@ -452,14 +486,26 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
452
486
 
453
487
  # Capture AIMessage tool_calls
454
488
  if msg_type == 'AIMessage' and hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
455
- new_tool_calls = []
456
- for tc in last_msg.tool_calls:
457
- serialized = _serialize_tool_call(tc)
458
- tool_call_map[serialized["id"]] = serialized
459
- new_tool_calls.append(serialized)
460
-
461
489
  with state_lock:
462
- current_state["tool_calls"].extend(new_tool_calls)
490
+ # Get existing tool call IDs to avoid duplicates
491
+ existing_ids = {tc.get("id") for tc in current_state["tool_calls"]}
492
+
493
+ for tc in last_msg.tool_calls:
494
+ serialized = _serialize_tool_call(tc)
495
+ tc_id = serialized["id"]
496
+
497
+ # Only add if not already in the list (avoid duplicates on resume)
498
+ if tc_id not in existing_ids:
499
+ tool_call_map[tc_id] = serialized
500
+ current_state["tool_calls"].append(serialized)
501
+ existing_ids.add(tc_id)
502
+ else:
503
+ # Update the map to reference the existing tool call
504
+ for existing_tc in current_state["tool_calls"]:
505
+ if existing_tc.get("id") == tc_id:
506
+ tool_call_map[tc_id] = existing_tc
507
+ break
508
+
463
509
  current_state["last_update"] = time.time()
464
510
 
465
511
  elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
@@ -488,10 +534,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
488
534
  content_lower.startswith("traceback")):
489
535
  status = "error"
490
536
 
491
- # Truncate result for display
492
- result_display = str(content)
493
- if len(result_display) > 1000:
494
- result_display = result_display[:1000] + "..."
537
+ # display_inline now pushes rich content directly to queue
538
+ # and returns a simple confirmation message, so no special handling needed
539
+ if isinstance(content, str):
540
+ # Truncate result for display
541
+ result_display = content[:1000] + "..." if len(content) > 1000 else content
542
+ else:
543
+ # Convert other types to string and truncate
544
+ result_display = str(content)
545
+ if len(result_display) > 1000:
546
+ result_display = result_display[:1000] + "..."
495
547
 
496
548
  _update_tool_call_result(tool_call_id, result_display, status)
497
549
 
@@ -696,6 +748,7 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
696
748
 
697
749
  with state_lock:
698
750
  current_state["running"] = False
751
+ current_state["stop_event"] = None # Clean up stop event
699
752
  current_state["last_update"] = time.time()
700
753
 
701
754
 
@@ -899,6 +952,7 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
899
952
 
900
953
  with state_lock:
901
954
  current_state["running"] = False
955
+ current_state["stop_event"] = None # Clean up stop event
902
956
  current_state["response"] = f"Action rejected{tool_info}: {reject_message}"
903
957
  current_state["last_update"] = time.time()
904
958
 
@@ -955,9 +1009,34 @@ def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
955
1009
  state["tool_calls"] = copy.deepcopy(current_state["tool_calls"])
956
1010
  state["todos"] = copy.deepcopy(current_state["todos"])
957
1011
  state["canvas"] = copy.deepcopy(current_state["canvas"])
1012
+ state["display_inline_items"] = copy.deepcopy(current_state.get("display_inline_items", []))
958
1013
  return state
959
1014
 
960
1015
 
1016
+ def push_display_inline_item(item: Dict[str, Any], session_id: Optional[str] = None):
1017
+ """Push a display_inline item to the agent state (thread-safe).
1018
+
1019
+ This is called by the display_inline tool to store rich content
1020
+ that bypasses LangGraph serialization.
1021
+
1022
+ Args:
1023
+ item: The display result dict with type, display_type, data, etc.
1024
+ session_id: Session ID for virtual FS mode, None for physical FS mode.
1025
+ """
1026
+ if USE_VIRTUAL_FS and session_id:
1027
+ current_state = _get_session_state(session_id)
1028
+ state_lock = _session_agents_lock
1029
+ else:
1030
+ current_state = _agent_state
1031
+ state_lock = _agent_state_lock
1032
+
1033
+ with state_lock:
1034
+ if "display_inline_items" not in current_state:
1035
+ current_state["display_inline_items"] = []
1036
+ current_state["display_inline_items"].append(item)
1037
+ current_state["last_update"] = time.time()
1038
+
1039
+
961
1040
  def reset_agent_state(session_id: Optional[str] = None):
962
1041
  """Reset agent state for a fresh session (thread-safe).
963
1042
 
@@ -979,8 +1058,11 @@ def reset_agent_state(session_id: Optional[str] = None):
979
1058
  current_state["thinking"] = ""
980
1059
  current_state["todos"] = []
981
1060
  current_state["tool_calls"] = []
1061
+ current_state["display_inline_items"] = []
982
1062
  current_state["response"] = ""
983
1063
  current_state["error"] = None
1064
+ current_state["stop_event"] = None
1065
+ current_state["stop_requested"] = False
984
1066
  current_state["interrupt"] = None
985
1067
  current_state["start_time"] = None
986
1068
  current_state["stop_requested"] = False
@@ -1095,8 +1177,10 @@ def display_initial_messages(history, theme, skip_render, session_initialized, s
1095
1177
  for msg in history:
1096
1178
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1097
1179
  messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1180
+ # Order: tool calls -> todos -> thinking -> display inline items
1098
1181
  # Render tool calls stored with this message
1099
1182
  if msg.get("tool_calls"):
1183
+ # Show collapsed tool calls section first
1100
1184
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1101
1185
  if tool_calls_block:
1102
1186
  messages.append(tool_calls_block)
@@ -1105,6 +1189,18 @@ def display_initial_messages(history, theme, skip_render, session_initialized, s
1105
1189
  todos_block = format_todos_inline(msg["todos"], colors)
1106
1190
  if todos_block:
1107
1191
  messages.append(todos_block)
1192
+ # Extract and show thinking from tool calls
1193
+ if msg.get("tool_calls"):
1194
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1195
+ messages.extend(thinking_blocks)
1196
+ # Extract and show display_inline results prominently
1197
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1198
+ messages.extend(inline_results)
1199
+ # Render display_inline items stored with this message
1200
+ if msg.get("display_inline_items"):
1201
+ for item in msg["display_inline_items"]:
1202
+ rendered = render_display_inline_result(item, colors)
1203
+ messages.append(rendered)
1108
1204
  return messages, False, True, new_session_id
1109
1205
 
1110
1206
 
@@ -1138,7 +1234,7 @@ def initialize_file_tree_for_session(session_initialized, session_id, current_wo
1138
1234
  current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
1139
1235
 
1140
1236
  # Build and render file tree
1141
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
1237
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, workspace_root=workspace_root)
1142
1238
 
1143
1239
 
1144
1240
  # Chat callbacks
@@ -1173,8 +1269,9 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1173
1269
  is_new = (i == len(history) - 1)
1174
1270
  msg_response_time = m.get("response_time") if m["role"] == "assistant" else None
1175
1271
  messages.append(format_message(m["role"], m["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
1176
- # Render tool calls stored with this message
1272
+ # Order: tool calls -> todos -> thinking -> display inline items
1177
1273
  if m.get("tool_calls"):
1274
+ # Show collapsed tool calls section first
1178
1275
  tool_calls_block = format_tool_calls_inline(m["tool_calls"], colors)
1179
1276
  if tool_calls_block:
1180
1277
  messages.append(tool_calls_block)
@@ -1183,6 +1280,13 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1183
1280
  todos_block = format_todos_inline(m["todos"], colors)
1184
1281
  if todos_block:
1185
1282
  messages.append(todos_block)
1283
+ # Extract and show thinking from tool calls
1284
+ if m.get("tool_calls"):
1285
+ thinking_blocks = extract_thinking_from_tool_calls(m["tool_calls"], colors)
1286
+ messages.extend(thinking_blocks)
1287
+ # Extract and show display_inline results prominently
1288
+ inline_results = extract_display_inline_results(m["tool_calls"], colors)
1289
+ messages.extend(inline_results)
1186
1290
 
1187
1291
  messages.append(format_loading(colors))
1188
1292
 
@@ -1228,14 +1332,18 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1228
1332
  history = history or []
1229
1333
  colors = get_colors(theme or "light")
1230
1334
 
1335
+ # Get display_inline items from agent state (bypasses LangGraph serialization)
1336
+ display_inline_items = state.get("display_inline_items", [])
1337
+
1231
1338
  def render_history_messages(history_items):
1232
- """Render all history items including tool calls and todos."""
1339
+ """Render all history items including tool calls, display_inline items, and todos."""
1233
1340
  messages = []
1234
1341
  for msg in history_items:
1235
1342
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1236
1343
  messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1237
- # Render tool calls stored with this message
1344
+ # Order: tool calls -> todos -> thinking -> display inline items
1238
1345
  if msg.get("tool_calls"):
1346
+ # Show collapsed tool calls section first
1239
1347
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1240
1348
  if tool_calls_block:
1241
1349
  messages.append(tool_calls_block)
@@ -1244,6 +1352,18 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1244
1352
  todos_block = format_todos_inline(msg["todos"], colors)
1245
1353
  if todos_block:
1246
1354
  messages.append(todos_block)
1355
+ # Extract and show thinking from tool calls
1356
+ if msg.get("tool_calls"):
1357
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1358
+ messages.extend(thinking_blocks)
1359
+ # Extract and show display_inline results prominently
1360
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1361
+ messages.extend(inline_results)
1362
+ # Render display_inline items stored with this message
1363
+ if msg.get("display_inline_items"):
1364
+ for item in msg["display_inline_items"]:
1365
+ rendered = render_display_inline_result(item, colors)
1366
+ messages.append(rendered)
1247
1367
  return messages
1248
1368
 
1249
1369
  # Check for interrupt (human-in-the-loop)
@@ -1251,13 +1371,9 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1251
1371
  # Agent is paused waiting for user input
1252
1372
  messages = render_history_messages(history)
1253
1373
 
1254
- # Add current turn's thinking/tool_calls/todos before interrupt
1255
- if state["thinking"]:
1256
- thinking_block = format_thinking(state["thinking"], colors)
1257
- if thinking_block:
1258
- messages.append(thinking_block)
1259
-
1374
+ # Order: tool calls -> todos -> thinking -> display inline items
1260
1375
  if state.get("tool_calls"):
1376
+ # Show collapsed tool calls section first
1261
1377
  tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
1262
1378
  if tool_calls_block:
1263
1379
  messages.append(tool_calls_block)
@@ -1267,6 +1383,19 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1267
1383
  if todos_block:
1268
1384
  messages.append(todos_block)
1269
1385
 
1386
+ if state.get("tool_calls"):
1387
+ # Extract and show thinking from tool calls
1388
+ thinking_blocks = extract_thinking_from_tool_calls(state["tool_calls"], colors)
1389
+ messages.extend(thinking_blocks)
1390
+ # Extract and show display_inline results prominently
1391
+ inline_results = extract_display_inline_results(state["tool_calls"], colors)
1392
+ messages.extend(inline_results)
1393
+
1394
+ # Render any queued display_inline items (bypasses LangGraph serialization)
1395
+ for item in display_inline_items:
1396
+ rendered = render_display_inline_result(item, colors)
1397
+ messages.append(rendered)
1398
+
1270
1399
  # Add interrupt UI
1271
1400
  interrupt_block = format_interrupt(state["interrupt"], colors)
1272
1401
  if interrupt_block:
@@ -1282,15 +1411,20 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1282
1411
  if state.get("start_time"):
1283
1412
  response_time = time.time() - state["start_time"]
1284
1413
 
1285
- # Agent finished - store tool calls and todos with the USER message (they appear after user msg)
1414
+ # Agent finished - store tool calls, todos, and display_inline items with the USER message
1415
+ # (they appear after user msg in the UI)
1416
+ saved_display_inline_items = False
1286
1417
  if history:
1287
- # Find the last user message and attach tool calls and todos to it
1418
+ # Find the last user message and attach tool calls, todos, and display_inline items to it
1288
1419
  for i in range(len(history) - 1, -1, -1):
1289
1420
  if history[i]["role"] == "user":
1290
1421
  if state.get("tool_calls"):
1291
1422
  history[i]["tool_calls"] = state["tool_calls"]
1292
1423
  if state.get("todos"):
1293
1424
  history[i]["todos"] = state["todos"]
1425
+ if display_inline_items:
1426
+ history[i]["display_inline_items"] = display_inline_items
1427
+ saved_display_inline_items = True
1294
1428
  break
1295
1429
 
1296
1430
  # Add assistant response to history (with response time)
@@ -1303,12 +1437,13 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1303
1437
  history.append(assistant_msg)
1304
1438
 
1305
1439
  # Render all history (tool calls and todos are now part of history)
1440
+ # Order: tool calls -> todos -> thinking -> display inline items
1306
1441
  final_messages = []
1307
1442
  for i, msg in enumerate(history):
1308
1443
  is_new = (i >= len(history) - 1)
1309
1444
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1310
1445
  final_messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
1311
- # Render tool calls stored with this message
1446
+ # Show collapsed tool calls section first
1312
1447
  if msg.get("tool_calls"):
1313
1448
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1314
1449
  if tool_calls_block:
@@ -1318,21 +1453,35 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1318
1453
  todos_block = format_todos_inline(msg["todos"], colors)
1319
1454
  if todos_block:
1320
1455
  final_messages.append(todos_block)
1456
+ # Extract and show thinking from tool calls
1457
+ if msg.get("tool_calls"):
1458
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1459
+ final_messages.extend(thinking_blocks)
1460
+ # Extract and show display_inline results prominently
1461
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1462
+ final_messages.extend(inline_results)
1463
+ # Render display_inline items stored with this message
1464
+ if msg.get("display_inline_items"):
1465
+ for item in msg["display_inline_items"]:
1466
+ rendered = render_display_inline_result(item, colors)
1467
+ final_messages.append(rendered)
1468
+
1469
+ # Render any NEW queued display_inline items only if not already saved to history
1470
+ # (avoids duplicate rendering)
1471
+ if not saved_display_inline_items:
1472
+ for item in display_inline_items:
1473
+ rendered = render_display_inline_result(item, colors)
1474
+ final_messages.append(rendered)
1321
1475
 
1322
1476
  # Disable polling, set skip flag to prevent display_initial_messages from re-rendering
1323
1477
  return final_messages, history, True, True
1324
1478
  else:
1325
- # Agent still running - show loading with current thinking/tool_calls/todos
1479
+ # Agent still running - show loading with current tool_calls/todos/thinking
1326
1480
  messages = render_history_messages(history)
1327
1481
 
1328
- # Add current thinking if available
1329
- if state["thinking"]:
1330
- thinking_block = format_thinking(state["thinking"], colors)
1331
- if thinking_block:
1332
- messages.append(thinking_block)
1333
-
1334
- # Add current tool calls if available
1482
+ # Order: tool calls -> todos -> thinking -> display inline items
1335
1483
  if state.get("tool_calls"):
1484
+ # Show collapsed tool calls section first
1336
1485
  tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
1337
1486
  if tool_calls_block:
1338
1487
  messages.append(tool_calls_block)
@@ -1343,6 +1492,19 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1343
1492
  if todos_block:
1344
1493
  messages.append(todos_block)
1345
1494
 
1495
+ if state.get("tool_calls"):
1496
+ # Extract and show thinking from tool calls
1497
+ thinking_blocks = extract_thinking_from_tool_calls(state["tool_calls"], colors)
1498
+ messages.extend(thinking_blocks)
1499
+ # Extract and show display_inline results prominently
1500
+ inline_results = extract_display_inline_results(state["tool_calls"], colors)
1501
+ messages.extend(inline_results)
1502
+
1503
+ # Render any queued display_inline items (bypasses LangGraph serialization)
1504
+ for item in display_inline_items:
1505
+ rendered = render_display_inline_result(item, colors)
1506
+ messages.append(rendered)
1507
+
1346
1508
  # Add loading indicator
1347
1509
  messages.append(format_loading(colors))
1348
1510
 
@@ -1388,12 +1550,14 @@ def handle_stop_button(n_clicks, history, theme, session_id):
1388
1550
  request_agent_stop(session_id)
1389
1551
 
1390
1552
  # Render current messages with a stopping indicator
1553
+ # Order: tool calls -> todos -> thinking -> display inline items
1391
1554
  def render_history_messages(history):
1392
1555
  messages = []
1393
1556
  for i, msg in enumerate(history):
1394
1557
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1395
1558
  messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1396
1559
  if msg.get("tool_calls"):
1560
+ # Show collapsed tool calls section first
1397
1561
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1398
1562
  if tool_calls_block:
1399
1563
  messages.append(tool_calls_block)
@@ -1401,6 +1565,18 @@ def handle_stop_button(n_clicks, history, theme, session_id):
1401
1565
  todos_block = format_todos_inline(msg["todos"], colors)
1402
1566
  if todos_block:
1403
1567
  messages.append(todos_block)
1568
+ if msg.get("tool_calls"):
1569
+ # Extract and show thinking from tool calls
1570
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1571
+ messages.extend(thinking_blocks)
1572
+ # Extract and show display_inline results prominently
1573
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1574
+ messages.extend(inline_results)
1575
+ # Render display_inline items stored with this message
1576
+ if msg.get("display_inline_items"):
1577
+ for item in msg["display_inline_items"]:
1578
+ rendered = render_display_inline_result(item, colors)
1579
+ messages.append(rendered)
1404
1580
  return messages
1405
1581
 
1406
1582
  messages = render_history_messages(history)
@@ -1476,12 +1652,13 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1476
1652
  resume_agent_from_interrupt(decision, action, session_id=session_id)
1477
1653
 
1478
1654
  # Show loading state while agent resumes
1655
+ # Order: tool calls -> todos -> thinking -> display inline items
1479
1656
  messages = []
1480
1657
  for msg in history:
1481
1658
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1482
1659
  messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1483
- # Render tool calls stored with this message
1484
1660
  if msg.get("tool_calls"):
1661
+ # Show collapsed tool calls section first
1485
1662
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1486
1663
  if tool_calls_block:
1487
1664
  messages.append(tool_calls_block)
@@ -1490,6 +1667,18 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1490
1667
  todos_block = format_todos_inline(msg["todos"], colors)
1491
1668
  if todos_block:
1492
1669
  messages.append(todos_block)
1670
+ if msg.get("tool_calls"):
1671
+ # Extract and show thinking from tool calls
1672
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1673
+ messages.extend(thinking_blocks)
1674
+ # Extract and show display_inline results prominently
1675
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1676
+ messages.extend(inline_results)
1677
+ # Render display_inline items stored with this message
1678
+ if msg.get("display_inline_items"):
1679
+ for item in msg["display_inline_items"]:
1680
+ rendered = render_display_inline_result(item, colors)
1681
+ messages.append(rendered)
1493
1682
 
1494
1683
  messages.append(format_loading(colors))
1495
1684
 
@@ -1576,7 +1765,8 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1576
1765
  loaded_content = render_file_tree(folder_items, colors, STYLES,
1577
1766
  level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
1578
1767
  parent_path=folder_rel_path,
1579
- expanded_folders=expanded_folders)
1768
+ expanded_folders=expanded_folders,
1769
+ workspace_root=workspace_root)
1580
1770
  new_children_content.append(loaded_content if loaded_content else current_content)
1581
1771
  except Exception as e:
1582
1772
  print(f"Error loading folder {folder_rel_path}: {e}")
@@ -1748,7 +1938,8 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1748
1938
  # Render new file tree (reset expanded folders when navigating)
1749
1939
  file_tree = render_file_tree(
1750
1940
  build_file_tree(workspace_full_path, workspace_full_path),
1751
- colors, STYLES
1941
+ colors, STYLES,
1942
+ workspace_root=workspace_root
1752
1943
  )
1753
1944
 
1754
1945
  return new_path, breadcrumb_children, file_tree, [] # Reset expanded folders
@@ -2014,6 +2205,141 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme, session_id):
2014
2205
  "color": colors["text_primary"],
2015
2206
  }
2016
2207
  )
2208
+ elif file_ext in ('.csv', '.tsv'):
2209
+ # CSV/TSV files - render as table with raw view option
2210
+ import io as _io
2211
+ try:
2212
+ import pandas as pd
2213
+ sep = '\t' if file_ext == '.tsv' else ','
2214
+ df = pd.read_csv(_io.StringIO(content), sep=sep)
2215
+
2216
+ # Pagination settings
2217
+ rows_per_page = 50
2218
+ total_rows = len(df)
2219
+ total_pages = max(1, (total_rows + rows_per_page - 1) // rows_per_page)
2220
+ current_page = 0
2221
+
2222
+ # Create table preview (first page)
2223
+ start_idx = current_page * rows_per_page
2224
+ end_idx = min(start_idx + rows_per_page, total_rows)
2225
+ preview_df = df.iloc[start_idx:end_idx]
2226
+
2227
+ # Row info for display
2228
+ if total_rows > rows_per_page:
2229
+ row_info = f"Rows {start_idx + 1}-{end_idx} of {total_rows}"
2230
+ else:
2231
+ row_info = f"{total_rows} rows"
2232
+
2233
+ modal_content = html.Div([
2234
+ # Tab buttons for switching views
2235
+ html.Div([
2236
+ html.Button("Table", id="html-preview-tab", n_clicks=0,
2237
+ className="html-tab-btn html-tab-active",
2238
+ style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
2239
+ "borderRadius": "4px", "cursor": "pointer",
2240
+ "background": colors["accent"], "color": "#fff"}),
2241
+ html.Button("Raw", id="html-source-tab", n_clicks=0,
2242
+ className="html-tab-btn",
2243
+ style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
2244
+ "borderRadius": "4px", "cursor": "pointer",
2245
+ "background": "transparent", "color": colors["text_primary"]}),
2246
+ ], style={"marginBottom": "12px", "display": "flex"}),
2247
+ # Row count info and pagination controls
2248
+ html.Div([
2249
+ html.Span(f"{len(df.columns)} columns, {row_info}", id="csv-row-info", style={
2250
+ "fontSize": "12px",
2251
+ "color": colors["text_muted"],
2252
+ }),
2253
+ # Pagination controls (only show if more than one page)
2254
+ html.Div([
2255
+ html.Button("◀", id="csv-prev-page", n_clicks=0,
2256
+ disabled=current_page == 0,
2257
+ style={
2258
+ "padding": "4px 8px", "border": f"1px solid {colors['border']}",
2259
+ "borderRadius": "4px", "cursor": "pointer",
2260
+ "background": "transparent", "color": colors["text_primary"],
2261
+ "marginRight": "8px", "fontSize": "12px",
2262
+ }),
2263
+ html.Span(f"Page {current_page + 1} of {total_pages}", id="csv-page-info",
2264
+ style={"fontSize": "12px", "color": colors["text_primary"]}),
2265
+ html.Button("▶", id="csv-next-page", n_clicks=0,
2266
+ disabled=current_page >= total_pages - 1,
2267
+ style={
2268
+ "padding": "4px 8px", "border": f"1px solid {colors['border']}",
2269
+ "borderRadius": "4px", "cursor": "pointer",
2270
+ "background": "transparent", "color": colors["text_primary"],
2271
+ "marginLeft": "8px", "fontSize": "12px",
2272
+ }),
2273
+ ], style={"display": "flex" if total_pages > 1 else "none", "alignItems": "center"}),
2274
+ ], style={"display": "flex", "justifyContent": "space-between", "alignItems": "center", "marginBottom": "8px"}),
2275
+ # Store CSV data for pagination
2276
+ dcc.Store(id="csv-data-store", data={
2277
+ "content": content,
2278
+ "sep": sep,
2279
+ "total_rows": total_rows,
2280
+ "total_pages": total_pages,
2281
+ "rows_per_page": rows_per_page,
2282
+ "current_page": current_page,
2283
+ }),
2284
+ # Table preview (default visible)
2285
+ html.Div([
2286
+ dcc.Markdown(
2287
+ preview_df.to_html(index=False, classes="csv-preview-table"),
2288
+ dangerously_allow_html=True,
2289
+ style={"overflow": "auto"}
2290
+ )
2291
+ ], id="html-preview-frame", className="csv-table-container", style={
2292
+ "border": f"1px solid {colors['border']}",
2293
+ "borderRadius": "4px",
2294
+ "background": colors["bg_secondary"],
2295
+ "maxHeight": "65vh",
2296
+ "overflow": "auto",
2297
+ }),
2298
+ # Raw CSV (hidden by default)
2299
+ html.Pre(
2300
+ content,
2301
+ id="html-source-code",
2302
+ style={
2303
+ "display": "none",
2304
+ "background": colors["bg_tertiary"],
2305
+ "padding": "16px",
2306
+ "fontSize": "12px",
2307
+ "fontFamily": "'IBM Plex Mono', monospace",
2308
+ "overflow": "auto",
2309
+ "maxHeight": "80vh",
2310
+ "whiteSpace": "pre-wrap",
2311
+ "wordBreak": "break-word",
2312
+ "margin": "0",
2313
+ "color": colors["text_primary"],
2314
+ "border": f"1px solid {colors['border']}",
2315
+ "borderRadius": "4px",
2316
+ }
2317
+ )
2318
+ ])
2319
+ except Exception as e:
2320
+ # Fall back to raw text if parsing fails
2321
+ modal_content = html.Div([
2322
+ html.Div(f"Could not parse as CSV: {e}", style={
2323
+ "fontSize": "12px",
2324
+ "color": colors["text_muted"],
2325
+ "marginBottom": "8px",
2326
+ }),
2327
+ html.Pre(
2328
+ content,
2329
+ style={
2330
+ "background": colors["bg_tertiary"],
2331
+ "padding": "16px",
2332
+ "fontSize": "12px",
2333
+ "fontFamily": "'IBM Plex Mono', monospace",
2334
+ "overflow": "auto",
2335
+ "maxHeight": "80vh",
2336
+ "whiteSpace": "pre-wrap",
2337
+ "wordBreak": "break-word",
2338
+ "margin": "0",
2339
+ "color": colors["text_primary"],
2340
+ }
2341
+ )
2342
+ ])
2017
2343
  else:
2018
2344
  # Regular text files
2019
2345
  modal_content = html.Pre(
@@ -2087,10 +2413,12 @@ def download_from_modal(n_clicks, file_path, session_id):
2087
2413
  Output("html-source-tab", "style")],
2088
2414
  [Input("html-preview-tab", "n_clicks"),
2089
2415
  Input("html-source-tab", "n_clicks")],
2090
- [State("theme-store", "data")],
2416
+ [State("theme-store", "data"),
2417
+ State("html-preview-frame", "style"),
2418
+ State("html-source-code", "style")],
2091
2419
  prevent_initial_call=True
2092
2420
  )
2093
- def toggle_html_view(preview_clicks, source_clicks, theme):
2421
+ def toggle_html_view(preview_clicks, source_clicks, theme, current_preview_style, current_source_style):
2094
2422
  """Toggle between HTML preview and source code view."""
2095
2423
  ctx = callback_context
2096
2424
  if not ctx.triggered:
@@ -2099,28 +2427,18 @@ def toggle_html_view(preview_clicks, source_clicks, theme):
2099
2427
  colors = get_colors(theme or "light")
2100
2428
  triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2101
2429
 
2102
- # Base styles
2103
- preview_frame_style = {
2104
- "width": "100%",
2105
- "height": "80vh",
2106
- "border": f"1px solid {colors['border']}",
2107
- "borderRadius": "4px",
2108
- "background": "#fff",
2109
- }
2110
- source_code_style = {
2430
+ # Preserve current styles and only update display property
2431
+ # This ensures background colors set by the modal content are preserved
2432
+ preview_frame_style = current_preview_style.copy() if current_preview_style else {}
2433
+ source_code_style = current_source_style.copy() if current_source_style else {}
2434
+
2435
+ # Update theme-sensitive properties
2436
+ source_code_style.update({
2111
2437
  "background": colors["bg_tertiary"],
2112
- "padding": "16px",
2113
- "fontSize": "12px",
2114
- "fontFamily": "'IBM Plex Mono', monospace",
2115
- "overflow": "auto",
2116
- "maxHeight": "80vh",
2117
- "whiteSpace": "pre-wrap",
2118
- "wordBreak": "break-word",
2119
- "margin": "0",
2120
2438
  "color": colors["text_primary"],
2121
2439
  "border": f"1px solid {colors['border']}",
2122
- "borderRadius": "4px",
2123
- }
2440
+ })
2441
+
2124
2442
  active_btn_style = {
2125
2443
  "marginRight": "8px", "padding": "6px 12px", "border": "none",
2126
2444
  "borderRadius": "4px", "cursor": "pointer",
@@ -2144,6 +2462,85 @@ def toggle_html_view(preview_clicks, source_clicks, theme):
2144
2462
  return preview_frame_style, source_code_style, active_btn_style, {**inactive_btn_style}
2145
2463
 
2146
2464
 
2465
+ # CSV pagination
2466
+ @app.callback(
2467
+ [Output("html-preview-frame", "children", allow_duplicate=True),
2468
+ Output("csv-row-info", "children"),
2469
+ Output("csv-page-info", "children"),
2470
+ Output("csv-prev-page", "disabled"),
2471
+ Output("csv-next-page", "disabled"),
2472
+ Output("csv-data-store", "data")],
2473
+ [Input("csv-prev-page", "n_clicks"),
2474
+ Input("csv-next-page", "n_clicks")],
2475
+ [State("csv-data-store", "data"),
2476
+ State("theme-store", "data")],
2477
+ prevent_initial_call=True
2478
+ )
2479
+ def paginate_csv(prev_clicks, next_clicks, csv_data, theme):
2480
+ """Handle CSV pagination."""
2481
+ ctx = callback_context
2482
+ if not ctx.triggered or not csv_data:
2483
+ raise PreventUpdate
2484
+
2485
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2486
+
2487
+ import io as _io
2488
+ import pandas as pd
2489
+
2490
+ # Get current state
2491
+ content = csv_data.get("content", "")
2492
+ sep = csv_data.get("sep", ",")
2493
+ total_rows = csv_data.get("total_rows", 0)
2494
+ total_pages = csv_data.get("total_pages", 1)
2495
+ rows_per_page = csv_data.get("rows_per_page", 50)
2496
+ current_page = csv_data.get("current_page", 0)
2497
+
2498
+ # Update page based on which button was clicked
2499
+ if triggered_id == "csv-prev-page" and current_page > 0:
2500
+ current_page -= 1
2501
+ elif triggered_id == "csv-next-page" and current_page < total_pages - 1:
2502
+ current_page += 1
2503
+ else:
2504
+ raise PreventUpdate
2505
+
2506
+ # Parse CSV and get the page slice
2507
+ try:
2508
+ df = pd.read_csv(_io.StringIO(content), sep=sep)
2509
+ start_idx = current_page * rows_per_page
2510
+ end_idx = min(start_idx + rows_per_page, total_rows)
2511
+ preview_df = df.iloc[start_idx:end_idx]
2512
+
2513
+ # Generate row info
2514
+ if total_rows > rows_per_page:
2515
+ row_info = f"{len(df.columns)} columns, Rows {start_idx + 1}-{end_idx} of {total_rows}"
2516
+ else:
2517
+ row_info = f"{len(df.columns)} columns, {total_rows} rows"
2518
+
2519
+ # Generate table HTML
2520
+ table_html = dcc.Markdown(
2521
+ preview_df.to_html(index=False, classes="csv-preview-table"),
2522
+ dangerously_allow_html=True,
2523
+ style={"overflow": "auto"}
2524
+ )
2525
+
2526
+ # Update pagination state
2527
+ updated_csv_data = {
2528
+ **csv_data,
2529
+ "current_page": current_page,
2530
+ }
2531
+
2532
+ return (
2533
+ table_html,
2534
+ row_info,
2535
+ f"Page {current_page + 1} of {total_pages}",
2536
+ current_page == 0, # prev disabled
2537
+ current_page >= total_pages - 1, # next disabled
2538
+ updated_csv_data
2539
+ )
2540
+ except Exception:
2541
+ raise PreventUpdate
2542
+
2543
+
2147
2544
  # Open terminal
2148
2545
  @app.callback(
2149
2546
  Output("open-terminal-btn", "n_clicks"),
@@ -2216,7 +2613,7 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_i
2216
2613
  current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
2217
2614
 
2218
2615
  # Refresh file tree for current workspace, preserving expanded folders
2219
- file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders)
2616
+ file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
2220
2617
 
2221
2618
  # Re-render canvas from current in-memory state (don't reload from file)
2222
2619
  # This preserves canvas items that may not have been exported to .canvas/canvas.md yet
@@ -2269,7 +2666,7 @@ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session
2269
2666
  except Exception as e:
2270
2667
  print(f"Upload error: {e}")
2271
2668
 
2272
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders)
2669
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
2273
2670
 
2274
2671
 
2275
2672
  # Create folder modal - open
@@ -2350,7 +2747,7 @@ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id, e
2350
2747
 
2351
2748
  try:
2352
2749
  folder_path.mkdir(parents=True, exist_ok=False)
2353
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders), "", ""
2750
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root), "", ""
2354
2751
  except Exception as e:
2355
2752
  return no_update, f"Error creating folder: {e}", no_update
2356
2753
 
@@ -2474,7 +2871,7 @@ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, vie
2474
2871
  current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
2475
2872
 
2476
2873
  # Refresh file tree, preserving expanded folder state
2477
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders)
2874
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
2478
2875
 
2479
2876
 
2480
2877
  # Open clear canvas confirmation modal
@@ -2760,6 +3157,133 @@ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, co
2760
3157
  raise PreventUpdate
2761
3158
 
2762
3159
 
3160
+ # =============================================================================
3161
+ # ADD DISPLAY_INLINE TO CANVAS CALLBACK
3162
+ # =============================================================================
3163
+
3164
+ @app.callback(
3165
+ [Output("canvas-content", "children", allow_duplicate=True),
3166
+ Output("sidebar-view-toggle", "value", allow_duplicate=True)],
3167
+ Input({"type": "add-display-to-canvas-btn", "index": ALL}, "n_clicks"),
3168
+ [State({"type": "display-inline-data", "index": ALL}, "data"),
3169
+ State("theme-store", "data"),
3170
+ State("collapsed-canvas-items", "data"),
3171
+ State("session-id", "data")],
3172
+ prevent_initial_call=True
3173
+ )
3174
+ def add_display_inline_to_canvas(n_clicks_list, data_list, theme, collapsed_ids, session_id):
3175
+ """Add a display_inline item to the canvas when the button is clicked.
3176
+
3177
+ This allows users to save inline display items to the canvas for persistent reference.
3178
+ """
3179
+ from .canvas import generate_canvas_id, export_canvas_to_markdown
3180
+ from datetime import datetime
3181
+
3182
+ # Check if any button was actually clicked
3183
+ if not n_clicks_list or not any(n_clicks_list):
3184
+ raise PreventUpdate
3185
+
3186
+ # Find which button was clicked
3187
+ ctx = callback_context
3188
+ if not ctx.triggered:
3189
+ raise PreventUpdate
3190
+
3191
+ triggered = ctx.triggered[0]
3192
+ triggered_id = triggered["prop_id"]
3193
+
3194
+ # Parse the pattern-matching ID to get the index
3195
+ try:
3196
+ # Format: {"type":"add-display-to-canvas-btn","index":"abc123"}.n_clicks
3197
+ id_part = triggered_id.rsplit(".", 1)[0]
3198
+ id_dict = json.loads(id_part)
3199
+ clicked_index = id_dict.get("index")
3200
+ except (json.JSONDecodeError, KeyError, AttributeError):
3201
+ raise PreventUpdate
3202
+
3203
+ if not clicked_index:
3204
+ raise PreventUpdate
3205
+
3206
+ # Find the corresponding data
3207
+ display_data = None
3208
+ for data in data_list:
3209
+ if data and data.get("_item_id") == clicked_index:
3210
+ display_data = data
3211
+ break
3212
+
3213
+ if not display_data:
3214
+ raise PreventUpdate
3215
+
3216
+ colors = get_colors(theme or "light")
3217
+ collapsed_ids = collapsed_ids or []
3218
+
3219
+ # Get workspace for this session (virtual or physical)
3220
+ workspace_root = get_workspace_for_session(session_id)
3221
+
3222
+ # Convert display_inline result to canvas item format
3223
+ display_type = display_data.get("display_type", "text")
3224
+ title = display_data.get("title")
3225
+ data = display_data.get("data")
3226
+
3227
+ # Generate new canvas ID and timestamp
3228
+ canvas_id = generate_canvas_id()
3229
+ created_at = datetime.now().isoformat()
3230
+
3231
+ # Map display_inline types to canvas types
3232
+ canvas_item = {
3233
+ "id": canvas_id,
3234
+ "created_at": created_at,
3235
+ }
3236
+
3237
+ if title:
3238
+ canvas_item["title"] = title
3239
+
3240
+ if display_type == "image":
3241
+ canvas_item["type"] = "image"
3242
+ canvas_item["data"] = data # base64 image data
3243
+ elif display_type == "plotly":
3244
+ canvas_item["type"] = "plotly"
3245
+ canvas_item["data"] = data # Plotly JSON
3246
+ elif display_type == "dataframe":
3247
+ canvas_item["type"] = "dataframe"
3248
+ canvas_item["data"] = display_data.get("csv", {}).get("data", [])
3249
+ canvas_item["columns"] = display_data.get("csv", {}).get("columns", [])
3250
+ canvas_item["html"] = display_data.get("csv", {}).get("html", "")
3251
+ elif display_type == "pdf":
3252
+ canvas_item["type"] = "pdf"
3253
+ canvas_item["data"] = data # base64 PDF data
3254
+ canvas_item["mime_type"] = display_data.get("mime_type", "application/pdf")
3255
+ elif display_type == "html":
3256
+ canvas_item["type"] = "markdown"
3257
+ canvas_item["data"] = data # Store HTML as markdown (will render)
3258
+ elif display_type == "json":
3259
+ canvas_item["type"] = "markdown"
3260
+ canvas_item["data"] = f"```json\n{json.dumps(data, indent=2)}\n```"
3261
+ else:
3262
+ # text or other
3263
+ canvas_item["type"] = "markdown"
3264
+ canvas_item["data"] = str(data) if data else ""
3265
+
3266
+ # Add item to canvas (session-specific in virtual FS mode)
3267
+ if USE_VIRTUAL_FS and session_id:
3268
+ current_state = _get_session_state(session_id)
3269
+ with _session_agents_lock:
3270
+ current_state["canvas"].append(canvas_item)
3271
+ canvas_items = current_state["canvas"].copy()
3272
+ else:
3273
+ with _agent_state_lock:
3274
+ _agent_state["canvas"].append(canvas_item)
3275
+ canvas_items = _agent_state["canvas"].copy()
3276
+
3277
+ # Export updated canvas to markdown file
3278
+ try:
3279
+ export_canvas_to_markdown(canvas_items, workspace_root)
3280
+ except Exception as e:
3281
+ print(f"Failed to export canvas after adding display item: {e}")
3282
+
3283
+ # Render updated canvas and switch to canvas view
3284
+ return render_canvas_items(canvas_items, colors, collapsed_ids), "canvas"
3285
+
3286
+
2763
3287
  # =============================================================================
2764
3288
  # THEME TOGGLE CALLBACK - Using DMC 2.4 forceColorScheme
2765
3289
  # =============================================================================