cowork-dash 0.1.9__py3-none-any.whl → 0.2.1__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,8 @@ 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
+ render_ordered_content_items
39
40
  )
40
41
  from .layout import create_layout as create_layout_component
41
42
  from .virtual_fs import get_session_manager
@@ -251,6 +252,8 @@ _agent_state = {
251
252
  "thinking": "",
252
253
  "todos": [],
253
254
  "tool_calls": [], # Current turn's tool calls (reset each turn)
255
+ "content_items": [], # Ordered list of content: {"type": "text"|"thinking"|"tool_calls", "content": ...}
256
+ "display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
254
257
  "canvas": load_canvas_from_markdown(WORKSPACE_ROOT) if not USE_VIRTUAL_FS else [], # Load from canvas.md if exists (physical FS only)
255
258
  "response": "",
256
259
  "error": None,
@@ -258,6 +261,7 @@ _agent_state = {
258
261
  "last_update": time.time(),
259
262
  "start_time": None, # Track when agent started for response time calculation
260
263
  "stop_requested": False, # Flag to request agent stop
264
+ "stop_event": None, # Threading event for immediate stop signaling
261
265
  }
262
266
  _agent_state_lock = threading.Lock()
263
267
 
@@ -275,11 +279,14 @@ def _get_default_agent_state() -> Dict[str, Any]:
275
279
  "thinking": "",
276
280
  "todos": [],
277
281
  "tool_calls": [],
282
+ "content_items": [], # Ordered list of content: {"type": "text"|"thinking"|"tool_calls", "content": ...}
283
+ "display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
278
284
  "canvas": [],
279
285
  "response": "",
280
286
  "error": None,
281
287
  "interrupt": None,
282
288
  "last_update": time.time(),
289
+ "stop_event": None, # Threading event for immediate stop signaling
283
290
  "start_time": None,
284
291
  "stop_requested": False,
285
292
  }
@@ -323,7 +330,9 @@ def _get_session_state_lock() -> threading.Lock:
323
330
 
324
331
 
325
332
  def request_agent_stop(session_id: Optional[str] = None):
326
- """Request the agent to stop execution.
333
+ """Request the agent to stop execution immediately.
334
+
335
+ Sets the stop_requested flag and signals the stop_event for immediate interruption.
327
336
 
328
337
  Args:
329
338
  session_id: Session ID for virtual FS mode, None for physical FS mode.
@@ -333,10 +342,16 @@ def request_agent_stop(session_id: Optional[str] = None):
333
342
  with _session_agents_lock:
334
343
  state["stop_requested"] = True
335
344
  state["last_update"] = time.time()
345
+ # Signal the stop event for immediate interruption
346
+ if state.get("stop_event"):
347
+ state["stop_event"].set()
336
348
  else:
337
349
  with _agent_state_lock:
338
350
  _agent_state["stop_requested"] = True
339
351
  _agent_state["last_update"] = time.time()
352
+ # Signal the stop event for immediate interruption
353
+ if _agent_state.get("stop_event"):
354
+ _agent_state["stop_event"].set()
340
355
 
341
356
 
342
357
  def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None, session_id: Optional[str] = None):
@@ -367,6 +382,12 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
367
382
  current_state["running"] = False
368
383
  return
369
384
 
385
+ # Create a stop event for immediate interruption
386
+ stop_event = threading.Event()
387
+ with state_lock:
388
+ current_state["stop_event"] = stop_event
389
+ current_state["stop_requested"] = False # Reset stop flag
390
+
370
391
  # Track tool calls by their ID for updating status
371
392
  tool_call_map = {}
372
393
 
@@ -413,6 +434,17 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
413
434
  # Resume from interrupt
414
435
  from langgraph.types import Command
415
436
  agent_input = Command(resume=resume_data)
437
+
438
+ # Rebuild tool_call_map from existing tool calls and mark pending ones as running
439
+ with state_lock:
440
+ for tc in current_state.get("tool_calls", []):
441
+ tc_id = tc.get("id")
442
+ if tc_id:
443
+ tool_call_map[tc_id] = tc
444
+ # Mark pending tool calls back to running since we're resuming
445
+ if tc.get("status") == "pending":
446
+ tc["status"] = "running"
447
+ current_state["last_update"] = time.time()
416
448
  else:
417
449
  # Inject workspace context into the message if available
418
450
  if workspace_path:
@@ -423,14 +455,15 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
423
455
  agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
424
456
 
425
457
  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."
458
+ # Check if stop was requested (via flag or event)
459
+ if stop_event.is_set() or current_state.get("stop_requested"):
460
+ with state_lock:
461
+ current_state["response"] = current_state.get("response", "") + "\n\n⏹️ Agent stopped by user."
430
462
  current_state["running"] = False
431
463
  current_state["stop_requested"] = False
464
+ current_state["stop_event"] = None
432
465
  current_state["last_update"] = time.time()
433
- return
466
+ return
434
467
 
435
468
  # Check for interrupt
436
469
  if isinstance(update, dict) and "__interrupt__" in update:
@@ -439,6 +472,10 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
439
472
  with state_lock:
440
473
  current_state["interrupt"] = interrupt_data
441
474
  current_state["running"] = False # Pause until user responds
475
+ # Mark any "running" tool calls as "pending" since we're waiting for user approval
476
+ for tc in current_state["tool_calls"]:
477
+ if tc.get("status") == "running":
478
+ tc["status"] = "pending"
442
479
  current_state["last_update"] = time.time()
443
480
  return # Exit stream, wait for user to resume
444
481
 
@@ -450,17 +487,59 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
450
487
  last_msg = msgs[-1] if isinstance(msgs, list) else msgs
451
488
  msg_type = last_msg.__class__.__name__ if hasattr(last_msg, '__class__') else None
452
489
 
453
- # Capture AIMessage tool_calls
454
- 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)
490
+ # Capture AIMessage content and tool_calls
491
+ if msg_type == 'AIMessage':
492
+ # First, capture any text content from the AIMessage
493
+ if hasattr(last_msg, 'content') and last_msg.content:
494
+ content = last_msg.content
495
+ response_text = ""
496
+ if isinstance(content, str):
497
+ response_text = re.sub(
498
+ r"\{'id':\s*'[^']+',\s*'input':\s*\{.*?\},\s*'name':\s*'[^']+',\s*'type':\s*'tool_use'\}",
499
+ '', content, flags=re.DOTALL
500
+ ).strip()
501
+ elif isinstance(content, list):
502
+ text_parts = [
503
+ block.get("text", "") if isinstance(block, dict) and block.get("type") == "text" else ""
504
+ for block in content
505
+ ]
506
+ response_text = " ".join(filter(None, text_parts)).strip()
460
507
 
461
- with state_lock:
462
- current_state["tool_calls"].extend(new_tool_calls)
463
- current_state["last_update"] = time.time()
508
+ if response_text:
509
+ with state_lock:
510
+ current_state["response"] = response_text
511
+ # Add text to ordered content list
512
+ if "content_items" not in current_state:
513
+ current_state["content_items"] = []
514
+ current_state["content_items"].append({
515
+ "type": "text",
516
+ "content": response_text
517
+ })
518
+ current_state["last_update"] = time.time()
519
+
520
+ # Then, capture any tool_calls
521
+ if hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
522
+ with state_lock:
523
+ # Get existing tool call IDs to avoid duplicates
524
+ existing_ids = {tc.get("id") for tc in current_state["tool_calls"]}
525
+
526
+ for tc in last_msg.tool_calls:
527
+ serialized = _serialize_tool_call(tc)
528
+ tc_id = serialized["id"]
529
+
530
+ # Only add if not already in the list (avoid duplicates on resume)
531
+ if tc_id not in existing_ids:
532
+ tool_call_map[tc_id] = serialized
533
+ current_state["tool_calls"].append(serialized)
534
+ existing_ids.add(tc_id)
535
+ else:
536
+ # Update the map to reference the existing tool call
537
+ for existing_tc in current_state["tool_calls"]:
538
+ if existing_tc.get("id") == tc_id:
539
+ tool_call_map[tc_id] = existing_tc
540
+ break
541
+
542
+ current_state["last_update"] = time.time()
464
543
 
465
544
  elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
466
545
  # Update tool call status when we get the result
@@ -488,10 +567,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
488
567
  content_lower.startswith("traceback")):
489
568
  status = "error"
490
569
 
491
- # Truncate result for display
492
- result_display = str(content)
493
- if len(result_display) > 1000:
494
- result_display = result_display[:1000] + "..."
570
+ # display_inline now pushes rich content directly to queue
571
+ # and returns a simple confirmation message, so no special handling needed
572
+ if isinstance(content, str):
573
+ # Truncate result for display
574
+ result_display = content[:1000] + "..." if len(content) > 1000 else content
575
+ else:
576
+ # Convert other types to string and truncate
577
+ result_display = str(content)
578
+ if len(result_display) > 1000:
579
+ result_display = result_display[:1000] + "..."
495
580
 
496
581
  _update_tool_call_result(tool_call_id, result_display, status)
497
582
 
@@ -508,9 +593,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
508
593
  elif isinstance(content, dict):
509
594
  thinking_text = content.get('reflection', str(content))
510
595
 
511
- # Update state immediately
596
+ # Update state immediately - add to ordered content_items
512
597
  with state_lock:
513
598
  current_state["thinking"] = thinking_text
599
+ # Add thinking to ordered content list
600
+ if "content_items" not in current_state:
601
+ current_state["content_items"] = []
602
+ current_state["content_items"].append({
603
+ "type": "thinking",
604
+ "content": thinking_text
605
+ })
514
606
  current_state["last_update"] = time.time()
515
607
 
516
608
  elif last_msg.name == 'write_todos':
@@ -664,26 +756,6 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
664
756
  except Exception as e:
665
757
  print(f"Failed to export canvas: {e}")
666
758
 
667
- elif hasattr(last_msg, 'content'):
668
- content = last_msg.content
669
- response_text = ""
670
- if isinstance(content, str):
671
- response_text = re.sub(
672
- r"\{'id':\s*'[^']+',\s*'input':\s*\{.*?\},\s*'name':\s*'[^']+',\s*'type':\s*'tool_use'\}",
673
- '', content, flags=re.DOTALL
674
- ).strip()
675
- elif isinstance(content, list):
676
- text_parts = [
677
- block.get("text", "") if isinstance(block, dict) else str(block)
678
- for block in content
679
- ]
680
- response_text = " ".join(text_parts).strip()
681
-
682
- if response_text:
683
- with state_lock:
684
- current_state["response"] = response_text
685
- current_state["last_update"] = time.time()
686
-
687
759
  except Exception as e:
688
760
  with state_lock:
689
761
  current_state["error"] = str(e)
@@ -696,6 +768,7 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
696
768
 
697
769
  with state_lock:
698
770
  current_state["running"] = False
771
+ current_state["stop_event"] = None # Clean up stop event
699
772
  current_state["last_update"] = time.time()
700
773
 
701
774
 
@@ -822,6 +895,7 @@ def call_agent(message: str, resume_data: Dict = None, workspace_path: str = Non
822
895
  "thinking": "",
823
896
  "todos": [],
824
897
  "tool_calls": [], # Reset tool calls for this turn
898
+ "content_items": [], # Ordered list of content items
825
899
  "canvas": existing_canvas, # Preserve existing canvas
826
900
  "response": "",
827
901
  "error": None,
@@ -899,6 +973,7 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
899
973
 
900
974
  with state_lock:
901
975
  current_state["running"] = False
976
+ current_state["stop_event"] = None # Clean up stop event
902
977
  current_state["response"] = f"Action rejected{tool_info}: {reject_message}"
903
978
  current_state["last_update"] = time.time()
904
979
 
@@ -955,9 +1030,42 @@ def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
955
1030
  state["tool_calls"] = copy.deepcopy(current_state["tool_calls"])
956
1031
  state["todos"] = copy.deepcopy(current_state["todos"])
957
1032
  state["canvas"] = copy.deepcopy(current_state["canvas"])
1033
+ state["content_items"] = copy.deepcopy(current_state.get("content_items", []))
1034
+ state["display_inline_items"] = copy.deepcopy(current_state.get("display_inline_items", []))
958
1035
  return state
959
1036
 
960
1037
 
1038
+ def push_display_inline_item(item: Dict[str, Any], session_id: Optional[str] = None):
1039
+ """Push a display_inline item to the agent state (thread-safe).
1040
+
1041
+ This is called by the display_inline tool to store rich content
1042
+ that bypasses LangGraph serialization.
1043
+
1044
+ Args:
1045
+ item: The display result dict with type, display_type, data, etc.
1046
+ session_id: Session ID for virtual FS mode, None for physical FS mode.
1047
+ """
1048
+ if USE_VIRTUAL_FS and session_id:
1049
+ current_state = _get_session_state(session_id)
1050
+ state_lock = _session_agents_lock
1051
+ else:
1052
+ current_state = _agent_state
1053
+ state_lock = _agent_state_lock
1054
+
1055
+ with state_lock:
1056
+ if "display_inline_items" not in current_state:
1057
+ current_state["display_inline_items"] = []
1058
+ current_state["display_inline_items"].append(item)
1059
+ # Also add to ordered content_items for proper interleaving
1060
+ if "content_items" not in current_state:
1061
+ current_state["content_items"] = []
1062
+ current_state["content_items"].append({
1063
+ "type": "display_inline",
1064
+ "content": item # Store the full item dict
1065
+ })
1066
+ current_state["last_update"] = time.time()
1067
+
1068
+
961
1069
  def reset_agent_state(session_id: Optional[str] = None):
962
1070
  """Reset agent state for a fresh session (thread-safe).
963
1071
 
@@ -979,8 +1087,12 @@ def reset_agent_state(session_id: Optional[str] = None):
979
1087
  current_state["thinking"] = ""
980
1088
  current_state["todos"] = []
981
1089
  current_state["tool_calls"] = []
1090
+ current_state["content_items"] = []
1091
+ current_state["display_inline_items"] = []
982
1092
  current_state["response"] = ""
983
1093
  current_state["error"] = None
1094
+ current_state["stop_event"] = None
1095
+ current_state["stop_requested"] = False
984
1096
  current_state["interrupt"] = None
985
1097
  current_state["start_time"] = None
986
1098
  current_state["stop_requested"] = False
@@ -1094,17 +1206,49 @@ def display_initial_messages(history, theme, skip_render, session_initialized, s
1094
1206
  messages = []
1095
1207
  for msg in history:
1096
1208
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1097
- messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1098
- # Render tool calls stored with this message
1099
- if msg.get("tool_calls"):
1100
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1101
- if tool_calls_block:
1102
- messages.append(tool_calls_block)
1103
- # Render todos stored with this message
1104
- if msg.get("todos"):
1105
- todos_block = format_todos_inline(msg["todos"], colors)
1106
- if todos_block:
1107
- messages.append(todos_block)
1209
+
1210
+ # For user messages, render normally
1211
+ if msg["role"] == "user":
1212
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1213
+
1214
+ # Show collapsed tool calls section
1215
+ if msg.get("tool_calls"):
1216
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1217
+ if tool_calls_block:
1218
+ messages.append(tool_calls_block)
1219
+
1220
+ # Render todos
1221
+ if msg.get("todos"):
1222
+ todos_block = format_todos_inline(msg["todos"], colors)
1223
+ if todos_block:
1224
+ messages.append(todos_block)
1225
+
1226
+ # Render ordered content items (thinking + text + display_inline in order)
1227
+ if msg.get("content_items"):
1228
+ content_blocks = render_ordered_content_items(msg["content_items"], colors, STYLES)
1229
+ messages.extend(content_blocks)
1230
+ else:
1231
+ # Fallback: extract thinking from tool calls (old format)
1232
+ if msg.get("tool_calls"):
1233
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1234
+ messages.extend(thinking_blocks)
1235
+
1236
+ # Extract and show display_inline results from tool calls (old format)
1237
+ if msg.get("tool_calls"):
1238
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1239
+ messages.extend(inline_results)
1240
+
1241
+ # Render display_inline items stored with this message (old format)
1242
+ if msg.get("display_inline_items"):
1243
+ for item in msg["display_inline_items"]:
1244
+ rendered = render_display_inline_result(item, colors)
1245
+ messages.append(rendered)
1246
+ else:
1247
+ # For assistant messages, check if content_items was used (text already rendered above)
1248
+ # In the new model, assistant content may be empty if content_items is in the user message
1249
+ if msg["content"]:
1250
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1251
+
1108
1252
  return messages, False, True, new_session_id
1109
1253
 
1110
1254
 
@@ -1138,7 +1282,7 @@ def initialize_file_tree_for_session(session_initialized, session_id, current_wo
1138
1282
  current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
1139
1283
 
1140
1284
  # Build and render file tree
1141
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
1285
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, workspace_root=workspace_root)
1142
1286
 
1143
1287
 
1144
1288
  # Chat callbacks
@@ -1172,17 +1316,41 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1172
1316
  for i, m in enumerate(history):
1173
1317
  is_new = (i == len(history) - 1)
1174
1318
  msg_response_time = m.get("response_time") if m["role"] == "assistant" else None
1175
- 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
1177
- if m.get("tool_calls"):
1178
- tool_calls_block = format_tool_calls_inline(m["tool_calls"], colors)
1179
- if tool_calls_block:
1180
- messages.append(tool_calls_block)
1181
- # Render todos stored with this message
1182
- if m.get("todos"):
1183
- todos_block = format_todos_inline(m["todos"], colors)
1184
- if todos_block:
1185
- messages.append(todos_block)
1319
+
1320
+ # For user messages, render normally
1321
+ if m["role"] == "user":
1322
+ messages.append(format_message(m["role"], m["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
1323
+
1324
+ # Show collapsed tool calls section
1325
+ if m.get("tool_calls"):
1326
+ tool_calls_block = format_tool_calls_inline(m["tool_calls"], colors)
1327
+ if tool_calls_block:
1328
+ messages.append(tool_calls_block)
1329
+
1330
+ # Render todos
1331
+ if m.get("todos"):
1332
+ todos_block = format_todos_inline(m["todos"], colors)
1333
+ if todos_block:
1334
+ messages.append(todos_block)
1335
+
1336
+ # Render ordered content items (thinking + text + display_inline in order)
1337
+ if m.get("content_items"):
1338
+ content_blocks = render_ordered_content_items(m["content_items"], colors, STYLES)
1339
+ messages.extend(content_blocks)
1340
+ else:
1341
+ # Fallback: extract thinking from tool calls (old format)
1342
+ if m.get("tool_calls"):
1343
+ thinking_blocks = extract_thinking_from_tool_calls(m["tool_calls"], colors)
1344
+ messages.extend(thinking_blocks)
1345
+
1346
+ # Extract and show display_inline results from tool calls (old format)
1347
+ if m.get("tool_calls"):
1348
+ inline_results = extract_display_inline_results(m["tool_calls"], colors)
1349
+ messages.extend(inline_results)
1350
+ else:
1351
+ # For assistant messages, check if content_items was used
1352
+ if m["content"]:
1353
+ messages.append(format_message(m["role"], m["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
1186
1354
 
1187
1355
  messages.append(format_loading(colors))
1188
1356
 
@@ -1228,22 +1396,57 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1228
1396
  history = history or []
1229
1397
  colors = get_colors(theme or "light")
1230
1398
 
1399
+ # Get display_inline items from agent state (bypasses LangGraph serialization)
1400
+ display_inline_items = state.get("display_inline_items", [])
1401
+
1231
1402
  def render_history_messages(history_items):
1232
- """Render all history items including tool calls and todos."""
1403
+ """Render all history items including tool calls, display_inline items, and todos."""
1233
1404
  messages = []
1234
1405
  for msg in history_items:
1235
1406
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1236
- messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1237
- # Render tool calls stored with this message
1238
- if msg.get("tool_calls"):
1239
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1240
- if tool_calls_block:
1241
- messages.append(tool_calls_block)
1242
- # Render todos stored with this message
1243
- if msg.get("todos"):
1244
- todos_block = format_todos_inline(msg["todos"], colors)
1245
- if todos_block:
1246
- messages.append(todos_block)
1407
+
1408
+ # For user messages, render normally
1409
+ if msg["role"] == "user":
1410
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1411
+
1412
+ # Show collapsed tool calls section
1413
+ if msg.get("tool_calls"):
1414
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1415
+ if tool_calls_block:
1416
+ messages.append(tool_calls_block)
1417
+
1418
+ # Render todos
1419
+ if msg.get("todos"):
1420
+ todos_block = format_todos_inline(msg["todos"], colors)
1421
+ if todos_block:
1422
+ messages.append(todos_block)
1423
+
1424
+ # Render ordered content items (thinking + text + display_inline in order)
1425
+ if msg.get("content_items"):
1426
+ content_blocks = render_ordered_content_items(msg["content_items"], colors, STYLES)
1427
+ messages.extend(content_blocks)
1428
+ else:
1429
+ # Fallback: extract thinking from tool calls (old format)
1430
+ if msg.get("tool_calls"):
1431
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1432
+ messages.extend(thinking_blocks)
1433
+
1434
+ # Extract and show display_inline results from tool calls (old format)
1435
+ if msg.get("tool_calls"):
1436
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1437
+ messages.extend(inline_results)
1438
+
1439
+ # Render display_inline items stored with this message (old format)
1440
+ if msg.get("display_inline_items"):
1441
+ for item in msg["display_inline_items"]:
1442
+ rendered = render_display_inline_result(item, colors)
1443
+ messages.append(rendered)
1444
+ else:
1445
+ # For assistant messages, check if content_items was used (text already rendered above)
1446
+ # In the new model, assistant content may be empty if content_items is in the user message
1447
+ if msg["content"]:
1448
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1449
+
1247
1450
  return messages
1248
1451
 
1249
1452
  # Check for interrupt (human-in-the-loop)
@@ -1251,22 +1454,38 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1251
1454
  # Agent is paused waiting for user input
1252
1455
  messages = render_history_messages(history)
1253
1456
 
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
-
1457
+ # Show collapsed tool calls section
1260
1458
  if state.get("tool_calls"):
1261
1459
  tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
1262
1460
  if tool_calls_block:
1263
1461
  messages.append(tool_calls_block)
1264
1462
 
1463
+ # Render todos
1265
1464
  if state["todos"]:
1266
1465
  todos_block = format_todos_inline(state["todos"], colors)
1267
1466
  if todos_block:
1268
1467
  messages.append(todos_block)
1269
1468
 
1469
+ # Render ordered content items (thinking + text + display_inline in order)
1470
+ if state.get("content_items"):
1471
+ content_blocks = render_ordered_content_items(state["content_items"], colors, STYLES)
1472
+ messages.extend(content_blocks)
1473
+ else:
1474
+ # Fallback: extract thinking from tool calls
1475
+ if state.get("tool_calls"):
1476
+ thinking_blocks = extract_thinking_from_tool_calls(state["tool_calls"], colors)
1477
+ messages.extend(thinking_blocks)
1478
+
1479
+ # Extract and show display_inline results from tool calls (old format)
1480
+ if state.get("tool_calls"):
1481
+ inline_results = extract_display_inline_results(state["tool_calls"], colors)
1482
+ messages.extend(inline_results)
1483
+
1484
+ # Render any queued display_inline items (old format)
1485
+ for item in display_inline_items:
1486
+ rendered = render_display_inline_result(item, colors)
1487
+ messages.append(rendered)
1488
+
1270
1489
  # Add interrupt UI
1271
1490
  interrupt_block = format_interrupt(state["interrupt"], colors)
1272
1491
  if interrupt_block:
@@ -1282,67 +1501,82 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1282
1501
  if state.get("start_time"):
1283
1502
  response_time = time.time() - state["start_time"]
1284
1503
 
1285
- # Agent finished - store tool calls and todos with the USER message (they appear after user msg)
1504
+ # Agent finished - store tool calls, todos, content_items, and display_inline items with the USER message
1505
+ # (they appear after user msg in the UI)
1506
+ saved_display_inline_items = False
1286
1507
  if history:
1287
- # Find the last user message and attach tool calls and todos to it
1508
+ # Find the last user message and attach tool calls, todos, content_items, and display_inline items to it
1288
1509
  for i in range(len(history) - 1, -1, -1):
1289
1510
  if history[i]["role"] == "user":
1290
1511
  if state.get("tool_calls"):
1291
1512
  history[i]["tool_calls"] = state["tool_calls"]
1292
1513
  if state.get("todos"):
1293
1514
  history[i]["todos"] = state["todos"]
1515
+ if state.get("content_items"):
1516
+ history[i]["content_items"] = state["content_items"]
1517
+ if display_inline_items:
1518
+ history[i]["display_inline_items"] = display_inline_items
1519
+ saved_display_inline_items = True
1294
1520
  break
1295
1521
 
1296
1522
  # Add assistant response to history (with response time)
1523
+ # Note: In the new model, content may be empty if content_items is used
1297
1524
  assistant_msg = {
1298
1525
  "role": "assistant",
1299
- "content": state["response"] if state["response"] else f"Error: {state['error']}",
1526
+ "content": "" if state.get("content_items") else (state["response"] if state["response"] else f"Error: {state['error']}"),
1300
1527
  "response_time": response_time,
1301
1528
  }
1302
1529
 
1303
1530
  history.append(assistant_msg)
1304
1531
 
1305
- # Render all history (tool calls and todos are now part of history)
1306
- final_messages = []
1307
- for i, msg in enumerate(history):
1308
- is_new = (i >= len(history) - 1)
1309
- msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1310
- 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
1312
- if msg.get("tool_calls"):
1313
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1314
- if tool_calls_block:
1315
- final_messages.append(tool_calls_block)
1316
- # Render todos stored with this message
1317
- if msg.get("todos"):
1318
- todos_block = format_todos_inline(msg["todos"], colors)
1319
- if todos_block:
1320
- final_messages.append(todos_block)
1532
+ # Render all history using render_history_messages (which handles content_items)
1533
+ final_messages = render_history_messages(history)
1534
+
1535
+ # Render any NEW queued display_inline items only if not already saved to history
1536
+ # (avoids duplicate rendering)
1537
+ if not saved_display_inline_items:
1538
+ for item in display_inline_items:
1539
+ rendered = render_display_inline_result(item, colors)
1540
+ final_messages.append(rendered)
1321
1541
 
1322
1542
  # Disable polling, set skip flag to prevent display_initial_messages from re-rendering
1323
1543
  return final_messages, history, True, True
1324
1544
  else:
1325
- # Agent still running - show loading with current thinking/tool_calls/todos
1545
+ # Agent still running - show loading with current tool_calls/todos/content_items
1326
1546
  messages = render_history_messages(history)
1327
1547
 
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
1548
+ # Show collapsed tool calls section
1335
1549
  if state.get("tool_calls"):
1336
1550
  tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
1337
1551
  if tool_calls_block:
1338
1552
  messages.append(tool_calls_block)
1339
1553
 
1340
- # Add current todos if available
1554
+ # Render todos
1341
1555
  if state["todos"]:
1342
1556
  todos_block = format_todos_inline(state["todos"], colors)
1343
1557
  if todos_block:
1344
1558
  messages.append(todos_block)
1345
1559
 
1560
+ # Render ordered content items (thinking + text + display_inline in order)
1561
+ if state.get("content_items"):
1562
+ content_blocks = render_ordered_content_items(state["content_items"], colors, STYLES)
1563
+ messages.extend(content_blocks)
1564
+ else:
1565
+ # Fallback: extract thinking from tool calls
1566
+ if state.get("tool_calls"):
1567
+ thinking_blocks = extract_thinking_from_tool_calls(state["tool_calls"], colors)
1568
+ messages.extend(thinking_blocks)
1569
+
1570
+ # Extract and show display_inline results from tool calls (old format)
1571
+ if state.get("tool_calls"):
1572
+ inline_results = extract_display_inline_results(state["tool_calls"], colors)
1573
+ messages.extend(inline_results)
1574
+
1575
+ # Render any queued display_inline items (old format)
1576
+ for item in display_inline_items:
1577
+ rendered = render_display_inline_result(item, colors)
1578
+ messages.append(rendered)
1579
+
1346
1580
  # Add loading indicator
1347
1581
  messages.append(format_loading(colors))
1348
1582
 
@@ -1388,22 +1622,55 @@ def handle_stop_button(n_clicks, history, theme, session_id):
1388
1622
  request_agent_stop(session_id)
1389
1623
 
1390
1624
  # Render current messages with a stopping indicator
1391
- def render_history_messages(history):
1625
+ def render_history_messages_local(history):
1392
1626
  messages = []
1393
1627
  for i, msg in enumerate(history):
1394
1628
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1395
- messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1396
- if msg.get("tool_calls"):
1397
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1398
- if tool_calls_block:
1399
- messages.append(tool_calls_block)
1400
- if msg.get("todos"):
1401
- todos_block = format_todos_inline(msg["todos"], colors)
1402
- if todos_block:
1403
- messages.append(todos_block)
1629
+
1630
+ # For user messages, render normally
1631
+ if msg["role"] == "user":
1632
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1633
+
1634
+ # Show collapsed tool calls section
1635
+ if msg.get("tool_calls"):
1636
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1637
+ if tool_calls_block:
1638
+ messages.append(tool_calls_block)
1639
+
1640
+ # Render todos
1641
+ if msg.get("todos"):
1642
+ todos_block = format_todos_inline(msg["todos"], colors)
1643
+ if todos_block:
1644
+ messages.append(todos_block)
1645
+
1646
+ # Render ordered content items (thinking + text + display_inline in order)
1647
+ if msg.get("content_items"):
1648
+ content_blocks = render_ordered_content_items(msg["content_items"], colors, STYLES)
1649
+ messages.extend(content_blocks)
1650
+ else:
1651
+ # Fallback: extract thinking from tool calls
1652
+ if msg.get("tool_calls"):
1653
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1654
+ messages.extend(thinking_blocks)
1655
+
1656
+ # Extract and show display_inline results from tool calls (old format)
1657
+ if msg.get("tool_calls"):
1658
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1659
+ messages.extend(inline_results)
1660
+
1661
+ # Render display_inline items stored with this message (old format)
1662
+ if msg.get("display_inline_items"):
1663
+ for item in msg["display_inline_items"]:
1664
+ rendered = render_display_inline_result(item, colors)
1665
+ messages.append(rendered)
1666
+ else:
1667
+ # For assistant messages, check if content_items was used
1668
+ if msg["content"]:
1669
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1670
+
1404
1671
  return messages
1405
1672
 
1406
- messages = render_history_messages(history)
1673
+ messages = render_history_messages_local(history)
1407
1674
 
1408
1675
  # Add stopping message
1409
1676
  messages.append(html.Div([
@@ -1479,17 +1746,47 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1479
1746
  messages = []
1480
1747
  for msg in history:
1481
1748
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1482
- messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1483
- # Render tool calls stored with this message
1484
- if msg.get("tool_calls"):
1485
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1486
- if tool_calls_block:
1487
- messages.append(tool_calls_block)
1488
- # Render todos stored with this message
1489
- if msg.get("todos"):
1490
- todos_block = format_todos_inline(msg["todos"], colors)
1491
- if todos_block:
1492
- messages.append(todos_block)
1749
+
1750
+ # For user messages, render normally
1751
+ if msg["role"] == "user":
1752
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1753
+
1754
+ # Show collapsed tool calls section
1755
+ if msg.get("tool_calls"):
1756
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1757
+ if tool_calls_block:
1758
+ messages.append(tool_calls_block)
1759
+
1760
+ # Render todos
1761
+ if msg.get("todos"):
1762
+ todos_block = format_todos_inline(msg["todos"], colors)
1763
+ if todos_block:
1764
+ messages.append(todos_block)
1765
+
1766
+ # Render ordered content items (thinking + text + display_inline in order)
1767
+ if msg.get("content_items"):
1768
+ content_blocks = render_ordered_content_items(msg["content_items"], colors, STYLES)
1769
+ messages.extend(content_blocks)
1770
+ else:
1771
+ # Fallback: extract thinking from tool calls
1772
+ if msg.get("tool_calls"):
1773
+ thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
1774
+ messages.extend(thinking_blocks)
1775
+
1776
+ # Extract and show display_inline results from tool calls (old format)
1777
+ if msg.get("tool_calls"):
1778
+ inline_results = extract_display_inline_results(msg["tool_calls"], colors)
1779
+ messages.extend(inline_results)
1780
+
1781
+ # Render display_inline items stored with this message (old format)
1782
+ if msg.get("display_inline_items"):
1783
+ for item in msg["display_inline_items"]:
1784
+ rendered = render_display_inline_result(item, colors)
1785
+ messages.append(rendered)
1786
+ else:
1787
+ # For assistant messages, check if content_items was used
1788
+ if msg["content"]:
1789
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1493
1790
 
1494
1791
  messages.append(format_loading(colors))
1495
1792
 
@@ -1576,7 +1873,8 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1576
1873
  loaded_content = render_file_tree(folder_items, colors, STYLES,
1577
1874
  level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
1578
1875
  parent_path=folder_rel_path,
1579
- expanded_folders=expanded_folders)
1876
+ expanded_folders=expanded_folders,
1877
+ workspace_root=workspace_root)
1580
1878
  new_children_content.append(loaded_content if loaded_content else current_content)
1581
1879
  except Exception as e:
1582
1880
  print(f"Error loading folder {folder_rel_path}: {e}")
@@ -1748,7 +2046,8 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1748
2046
  # Render new file tree (reset expanded folders when navigating)
1749
2047
  file_tree = render_file_tree(
1750
2048
  build_file_tree(workspace_full_path, workspace_full_path),
1751
- colors, STYLES
2049
+ colors, STYLES,
2050
+ workspace_root=workspace_root
1752
2051
  )
1753
2052
 
1754
2053
  return new_path, breadcrumb_children, file_tree, [] # Reset expanded folders
@@ -2014,6 +2313,141 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme, session_id):
2014
2313
  "color": colors["text_primary"],
2015
2314
  }
2016
2315
  )
2316
+ elif file_ext in ('.csv', '.tsv'):
2317
+ # CSV/TSV files - render as table with raw view option
2318
+ import io as _io
2319
+ try:
2320
+ import pandas as pd
2321
+ sep = '\t' if file_ext == '.tsv' else ','
2322
+ df = pd.read_csv(_io.StringIO(content), sep=sep)
2323
+
2324
+ # Pagination settings
2325
+ rows_per_page = 50
2326
+ total_rows = len(df)
2327
+ total_pages = max(1, (total_rows + rows_per_page - 1) // rows_per_page)
2328
+ current_page = 0
2329
+
2330
+ # Create table preview (first page)
2331
+ start_idx = current_page * rows_per_page
2332
+ end_idx = min(start_idx + rows_per_page, total_rows)
2333
+ preview_df = df.iloc[start_idx:end_idx]
2334
+
2335
+ # Row info for display
2336
+ if total_rows > rows_per_page:
2337
+ row_info = f"Rows {start_idx + 1}-{end_idx} of {total_rows}"
2338
+ else:
2339
+ row_info = f"{total_rows} rows"
2340
+
2341
+ modal_content = html.Div([
2342
+ # Tab buttons for switching views
2343
+ html.Div([
2344
+ html.Button("Table", id="html-preview-tab", n_clicks=0,
2345
+ className="html-tab-btn html-tab-active",
2346
+ style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
2347
+ "borderRadius": "4px", "cursor": "pointer",
2348
+ "background": colors["accent"], "color": "#fff"}),
2349
+ html.Button("Raw", id="html-source-tab", n_clicks=0,
2350
+ className="html-tab-btn",
2351
+ style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
2352
+ "borderRadius": "4px", "cursor": "pointer",
2353
+ "background": "transparent", "color": colors["text_primary"]}),
2354
+ ], style={"marginBottom": "12px", "display": "flex"}),
2355
+ # Row count info and pagination controls
2356
+ html.Div([
2357
+ html.Span(f"{len(df.columns)} columns, {row_info}", id="csv-row-info", style={
2358
+ "fontSize": "12px",
2359
+ "color": colors["text_muted"],
2360
+ }),
2361
+ # Pagination controls (only show if more than one page)
2362
+ html.Div([
2363
+ html.Button("◀", id="csv-prev-page", n_clicks=0,
2364
+ disabled=current_page == 0,
2365
+ style={
2366
+ "padding": "4px 8px", "border": f"1px solid {colors['border']}",
2367
+ "borderRadius": "4px", "cursor": "pointer",
2368
+ "background": "transparent", "color": colors["text_primary"],
2369
+ "marginRight": "8px", "fontSize": "12px",
2370
+ }),
2371
+ html.Span(f"Page {current_page + 1} of {total_pages}", id="csv-page-info",
2372
+ style={"fontSize": "12px", "color": colors["text_primary"]}),
2373
+ html.Button("▶", id="csv-next-page", n_clicks=0,
2374
+ disabled=current_page >= total_pages - 1,
2375
+ style={
2376
+ "padding": "4px 8px", "border": f"1px solid {colors['border']}",
2377
+ "borderRadius": "4px", "cursor": "pointer",
2378
+ "background": "transparent", "color": colors["text_primary"],
2379
+ "marginLeft": "8px", "fontSize": "12px",
2380
+ }),
2381
+ ], style={"display": "flex" if total_pages > 1 else "none", "alignItems": "center"}),
2382
+ ], style={"display": "flex", "justifyContent": "space-between", "alignItems": "center", "marginBottom": "8px"}),
2383
+ # Store CSV data for pagination
2384
+ dcc.Store(id="csv-data-store", data={
2385
+ "content": content,
2386
+ "sep": sep,
2387
+ "total_rows": total_rows,
2388
+ "total_pages": total_pages,
2389
+ "rows_per_page": rows_per_page,
2390
+ "current_page": current_page,
2391
+ }),
2392
+ # Table preview (default visible)
2393
+ html.Div([
2394
+ dcc.Markdown(
2395
+ preview_df.to_html(index=False, classes="csv-preview-table"),
2396
+ dangerously_allow_html=True,
2397
+ style={"overflow": "auto"}
2398
+ )
2399
+ ], id="html-preview-frame", className="csv-table-container", style={
2400
+ "border": f"1px solid {colors['border']}",
2401
+ "borderRadius": "4px",
2402
+ "background": colors["bg_secondary"],
2403
+ "maxHeight": "65vh",
2404
+ "overflow": "auto",
2405
+ }),
2406
+ # Raw CSV (hidden by default)
2407
+ html.Pre(
2408
+ content,
2409
+ id="html-source-code",
2410
+ style={
2411
+ "display": "none",
2412
+ "background": colors["bg_tertiary"],
2413
+ "padding": "16px",
2414
+ "fontSize": "12px",
2415
+ "fontFamily": "'IBM Plex Mono', monospace",
2416
+ "overflow": "auto",
2417
+ "maxHeight": "80vh",
2418
+ "whiteSpace": "pre-wrap",
2419
+ "wordBreak": "break-word",
2420
+ "margin": "0",
2421
+ "color": colors["text_primary"],
2422
+ "border": f"1px solid {colors['border']}",
2423
+ "borderRadius": "4px",
2424
+ }
2425
+ )
2426
+ ])
2427
+ except Exception as e:
2428
+ # Fall back to raw text if parsing fails
2429
+ modal_content = html.Div([
2430
+ html.Div(f"Could not parse as CSV: {e}", style={
2431
+ "fontSize": "12px",
2432
+ "color": colors["text_muted"],
2433
+ "marginBottom": "8px",
2434
+ }),
2435
+ html.Pre(
2436
+ content,
2437
+ style={
2438
+ "background": colors["bg_tertiary"],
2439
+ "padding": "16px",
2440
+ "fontSize": "12px",
2441
+ "fontFamily": "'IBM Plex Mono', monospace",
2442
+ "overflow": "auto",
2443
+ "maxHeight": "80vh",
2444
+ "whiteSpace": "pre-wrap",
2445
+ "wordBreak": "break-word",
2446
+ "margin": "0",
2447
+ "color": colors["text_primary"],
2448
+ }
2449
+ )
2450
+ ])
2017
2451
  else:
2018
2452
  # Regular text files
2019
2453
  modal_content = html.Pre(
@@ -2087,10 +2521,12 @@ def download_from_modal(n_clicks, file_path, session_id):
2087
2521
  Output("html-source-tab", "style")],
2088
2522
  [Input("html-preview-tab", "n_clicks"),
2089
2523
  Input("html-source-tab", "n_clicks")],
2090
- [State("theme-store", "data")],
2524
+ [State("theme-store", "data"),
2525
+ State("html-preview-frame", "style"),
2526
+ State("html-source-code", "style")],
2091
2527
  prevent_initial_call=True
2092
2528
  )
2093
- def toggle_html_view(preview_clicks, source_clicks, theme):
2529
+ def toggle_html_view(preview_clicks, source_clicks, theme, current_preview_style, current_source_style):
2094
2530
  """Toggle between HTML preview and source code view."""
2095
2531
  ctx = callback_context
2096
2532
  if not ctx.triggered:
@@ -2099,28 +2535,18 @@ def toggle_html_view(preview_clicks, source_clicks, theme):
2099
2535
  colors = get_colors(theme or "light")
2100
2536
  triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2101
2537
 
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 = {
2538
+ # Preserve current styles and only update display property
2539
+ # This ensures background colors set by the modal content are preserved
2540
+ preview_frame_style = current_preview_style.copy() if current_preview_style else {}
2541
+ source_code_style = current_source_style.copy() if current_source_style else {}
2542
+
2543
+ # Update theme-sensitive properties
2544
+ source_code_style.update({
2111
2545
  "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
2546
  "color": colors["text_primary"],
2121
2547
  "border": f"1px solid {colors['border']}",
2122
- "borderRadius": "4px",
2123
- }
2548
+ })
2549
+
2124
2550
  active_btn_style = {
2125
2551
  "marginRight": "8px", "padding": "6px 12px", "border": "none",
2126
2552
  "borderRadius": "4px", "cursor": "pointer",
@@ -2144,6 +2570,85 @@ def toggle_html_view(preview_clicks, source_clicks, theme):
2144
2570
  return preview_frame_style, source_code_style, active_btn_style, {**inactive_btn_style}
2145
2571
 
2146
2572
 
2573
+ # CSV pagination
2574
+ @app.callback(
2575
+ [Output("html-preview-frame", "children", allow_duplicate=True),
2576
+ Output("csv-row-info", "children"),
2577
+ Output("csv-page-info", "children"),
2578
+ Output("csv-prev-page", "disabled"),
2579
+ Output("csv-next-page", "disabled"),
2580
+ Output("csv-data-store", "data")],
2581
+ [Input("csv-prev-page", "n_clicks"),
2582
+ Input("csv-next-page", "n_clicks")],
2583
+ [State("csv-data-store", "data"),
2584
+ State("theme-store", "data")],
2585
+ prevent_initial_call=True
2586
+ )
2587
+ def paginate_csv(prev_clicks, next_clicks, csv_data, theme):
2588
+ """Handle CSV pagination."""
2589
+ ctx = callback_context
2590
+ if not ctx.triggered or not csv_data:
2591
+ raise PreventUpdate
2592
+
2593
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2594
+
2595
+ import io as _io
2596
+ import pandas as pd
2597
+
2598
+ # Get current state
2599
+ content = csv_data.get("content", "")
2600
+ sep = csv_data.get("sep", ",")
2601
+ total_rows = csv_data.get("total_rows", 0)
2602
+ total_pages = csv_data.get("total_pages", 1)
2603
+ rows_per_page = csv_data.get("rows_per_page", 50)
2604
+ current_page = csv_data.get("current_page", 0)
2605
+
2606
+ # Update page based on which button was clicked
2607
+ if triggered_id == "csv-prev-page" and current_page > 0:
2608
+ current_page -= 1
2609
+ elif triggered_id == "csv-next-page" and current_page < total_pages - 1:
2610
+ current_page += 1
2611
+ else:
2612
+ raise PreventUpdate
2613
+
2614
+ # Parse CSV and get the page slice
2615
+ try:
2616
+ df = pd.read_csv(_io.StringIO(content), sep=sep)
2617
+ start_idx = current_page * rows_per_page
2618
+ end_idx = min(start_idx + rows_per_page, total_rows)
2619
+ preview_df = df.iloc[start_idx:end_idx]
2620
+
2621
+ # Generate row info
2622
+ if total_rows > rows_per_page:
2623
+ row_info = f"{len(df.columns)} columns, Rows {start_idx + 1}-{end_idx} of {total_rows}"
2624
+ else:
2625
+ row_info = f"{len(df.columns)} columns, {total_rows} rows"
2626
+
2627
+ # Generate table HTML
2628
+ table_html = dcc.Markdown(
2629
+ preview_df.to_html(index=False, classes="csv-preview-table"),
2630
+ dangerously_allow_html=True,
2631
+ style={"overflow": "auto"}
2632
+ )
2633
+
2634
+ # Update pagination state
2635
+ updated_csv_data = {
2636
+ **csv_data,
2637
+ "current_page": current_page,
2638
+ }
2639
+
2640
+ return (
2641
+ table_html,
2642
+ row_info,
2643
+ f"Page {current_page + 1} of {total_pages}",
2644
+ current_page == 0, # prev disabled
2645
+ current_page >= total_pages - 1, # next disabled
2646
+ updated_csv_data
2647
+ )
2648
+ except Exception:
2649
+ raise PreventUpdate
2650
+
2651
+
2147
2652
  # Open terminal
2148
2653
  @app.callback(
2149
2654
  Output("open-terminal-btn", "n_clicks"),
@@ -2216,7 +2721,7 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_i
2216
2721
  current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
2217
2722
 
2218
2723
  # 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)
2724
+ 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
2725
 
2221
2726
  # Re-render canvas from current in-memory state (don't reload from file)
2222
2727
  # This preserves canvas items that may not have been exported to .canvas/canvas.md yet
@@ -2269,7 +2774,7 @@ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session
2269
2774
  except Exception as e:
2270
2775
  print(f"Upload error: {e}")
2271
2776
 
2272
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders)
2777
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
2273
2778
 
2274
2779
 
2275
2780
  # Create folder modal - open
@@ -2350,7 +2855,7 @@ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id, e
2350
2855
 
2351
2856
  try:
2352
2857
  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), "", ""
2858
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root), "", ""
2354
2859
  except Exception as e:
2355
2860
  return no_update, f"Error creating folder: {e}", no_update
2356
2861
 
@@ -2408,6 +2913,70 @@ def toggle_view(view_value):
2408
2913
  )
2409
2914
 
2410
2915
 
2916
+ # Sidebar collapse/expand toggle
2917
+ @app.callback(
2918
+ [Output("sidebar-panel", "style"),
2919
+ Output("sidebar-expand-btn", "style"),
2920
+ Output("resize-handle", "style"),
2921
+ Output("sidebar-collapsed", "data")],
2922
+ [Input("collapse-sidebar-btn", "n_clicks"),
2923
+ Input("expand-sidebar-btn", "n_clicks")],
2924
+ [State("sidebar-collapsed", "data")],
2925
+ prevent_initial_call=True
2926
+ )
2927
+ def toggle_sidebar_collapse(collapse_clicks, expand_clicks, is_collapsed):
2928
+ """Toggle sidebar between collapsed and expanded states."""
2929
+ ctx = callback_context
2930
+ if not ctx.triggered:
2931
+ raise PreventUpdate
2932
+
2933
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2934
+
2935
+ # Determine new state based on which button was clicked
2936
+ if triggered_id == "collapse-sidebar-btn":
2937
+ new_collapsed = True
2938
+ elif triggered_id == "expand-sidebar-btn":
2939
+ new_collapsed = False
2940
+ else:
2941
+ raise PreventUpdate
2942
+
2943
+ if new_collapsed:
2944
+ # Collapsed state - hide sidebar, show expand button
2945
+ return (
2946
+ {"display": "none"}, # Hide sidebar panel
2947
+ { # Show expand button
2948
+ "display": "flex",
2949
+ "alignItems": "flex-start",
2950
+ "paddingTop": "10px",
2951
+ "borderLeft": "1px solid var(--mantine-color-default-border)",
2952
+ "background": "var(--mantine-color-body)",
2953
+ },
2954
+ {"display": "none"}, # Hide resize handle
2955
+ True, # Store collapsed state
2956
+ )
2957
+ else:
2958
+ # Expanded state - show sidebar, hide expand button
2959
+ return (
2960
+ { # Show sidebar panel
2961
+ "flex": "1",
2962
+ "minWidth": "0",
2963
+ "minHeight": "0",
2964
+ "display": "flex",
2965
+ "flexDirection": "column",
2966
+ "background": "var(--mantine-color-body)",
2967
+ "borderLeft": "1px solid var(--mantine-color-default-border)",
2968
+ },
2969
+ {"display": "none"}, # Hide expand button
2970
+ { # Show resize handle
2971
+ "width": "3px",
2972
+ "cursor": "col-resize",
2973
+ "background": "transparent",
2974
+ "flexShrink": "0",
2975
+ },
2976
+ False, # Store expanded state
2977
+ )
2978
+
2979
+
2411
2980
  # Canvas content update
2412
2981
  @app.callback(
2413
2982
  Output("canvas-content", "children"),
@@ -2474,7 +3043,7 @@ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, vie
2474
3043
  current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
2475
3044
 
2476
3045
  # 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)
3046
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
2478
3047
 
2479
3048
 
2480
3049
  # Open clear canvas confirmation modal
@@ -2760,6 +3329,333 @@ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, co
2760
3329
  raise PreventUpdate
2761
3330
 
2762
3331
 
3332
+ # =============================================================================
3333
+ # ADD DISPLAY_INLINE TO CANVAS CALLBACK
3334
+ # =============================================================================
3335
+
3336
+ @app.callback(
3337
+ [Output("canvas-content", "children", allow_duplicate=True),
3338
+ Output("sidebar-view-toggle", "value", allow_duplicate=True)],
3339
+ Input({"type": "add-display-to-canvas-btn", "index": ALL}, "n_clicks"),
3340
+ [State({"type": "display-inline-data", "index": ALL}, "data"),
3341
+ State("theme-store", "data"),
3342
+ State("collapsed-canvas-items", "data"),
3343
+ State("session-id", "data")],
3344
+ prevent_initial_call=True
3345
+ )
3346
+ def add_display_inline_to_canvas(n_clicks_list, data_list, theme, collapsed_ids, session_id):
3347
+ """Add a display_inline item to the canvas when the button is clicked.
3348
+
3349
+ This allows users to save inline display items to the canvas for persistent reference.
3350
+ """
3351
+ from .canvas import generate_canvas_id, export_canvas_to_markdown
3352
+ from datetime import datetime
3353
+
3354
+ # Check if any button was actually clicked
3355
+ if not n_clicks_list or not any(n_clicks_list):
3356
+ raise PreventUpdate
3357
+
3358
+ # Find which button was clicked
3359
+ ctx = callback_context
3360
+ if not ctx.triggered:
3361
+ raise PreventUpdate
3362
+
3363
+ triggered = ctx.triggered[0]
3364
+ triggered_id = triggered["prop_id"]
3365
+
3366
+ # Parse the pattern-matching ID to get the index
3367
+ try:
3368
+ # Format: {"type":"add-display-to-canvas-btn","index":"abc123"}.n_clicks
3369
+ id_part = triggered_id.rsplit(".", 1)[0]
3370
+ id_dict = json.loads(id_part)
3371
+ clicked_index = id_dict.get("index")
3372
+ except (json.JSONDecodeError, KeyError, AttributeError):
3373
+ raise PreventUpdate
3374
+
3375
+ if not clicked_index:
3376
+ raise PreventUpdate
3377
+
3378
+ # Find the corresponding data
3379
+ display_data = None
3380
+ for data in data_list:
3381
+ if data and data.get("_item_id") == clicked_index:
3382
+ display_data = data
3383
+ break
3384
+
3385
+ if not display_data:
3386
+ raise PreventUpdate
3387
+
3388
+ colors = get_colors(theme or "light")
3389
+ collapsed_ids = collapsed_ids or []
3390
+
3391
+ # Get workspace for this session (virtual or physical)
3392
+ workspace_root = get_workspace_for_session(session_id)
3393
+
3394
+ # Convert display_inline result to canvas item format
3395
+ display_type = display_data.get("display_type", "text")
3396
+ title = display_data.get("title")
3397
+ data = display_data.get("data")
3398
+
3399
+ # Generate new canvas ID and timestamp
3400
+ canvas_id = generate_canvas_id()
3401
+ created_at = datetime.now().isoformat()
3402
+
3403
+ # Map display_inline types to canvas types
3404
+ canvas_item = {
3405
+ "id": canvas_id,
3406
+ "created_at": created_at,
3407
+ }
3408
+
3409
+ if title:
3410
+ canvas_item["title"] = title
3411
+
3412
+ if display_type == "image":
3413
+ canvas_item["type"] = "image"
3414
+ canvas_item["data"] = data # base64 image data
3415
+ elif display_type == "plotly":
3416
+ canvas_item["type"] = "plotly"
3417
+ canvas_item["data"] = data # Plotly JSON
3418
+ elif display_type == "dataframe":
3419
+ canvas_item["type"] = "dataframe"
3420
+ canvas_item["data"] = display_data.get("csv", {}).get("data", [])
3421
+ canvas_item["columns"] = display_data.get("csv", {}).get("columns", [])
3422
+ canvas_item["html"] = display_data.get("csv", {}).get("html", "")
3423
+ elif display_type == "pdf":
3424
+ canvas_item["type"] = "pdf"
3425
+ canvas_item["data"] = data # base64 PDF data
3426
+ canvas_item["mime_type"] = display_data.get("mime_type", "application/pdf")
3427
+ elif display_type == "html":
3428
+ canvas_item["type"] = "markdown"
3429
+ canvas_item["data"] = data # Store HTML as markdown (will render)
3430
+ elif display_type == "json":
3431
+ canvas_item["type"] = "markdown"
3432
+ canvas_item["data"] = f"```json\n{json.dumps(data, indent=2)}\n```"
3433
+ else:
3434
+ # text or other
3435
+ canvas_item["type"] = "markdown"
3436
+ canvas_item["data"] = str(data) if data else ""
3437
+
3438
+ # Add item to canvas (session-specific in virtual FS mode)
3439
+ if USE_VIRTUAL_FS and session_id:
3440
+ current_state = _get_session_state(session_id)
3441
+ with _session_agents_lock:
3442
+ current_state["canvas"].append(canvas_item)
3443
+ canvas_items = current_state["canvas"].copy()
3444
+ else:
3445
+ with _agent_state_lock:
3446
+ _agent_state["canvas"].append(canvas_item)
3447
+ canvas_items = _agent_state["canvas"].copy()
3448
+
3449
+ # Export updated canvas to markdown file
3450
+ try:
3451
+ export_canvas_to_markdown(canvas_items, workspace_root)
3452
+ except Exception as e:
3453
+ print(f"Failed to export canvas after adding display item: {e}")
3454
+
3455
+ # Render updated canvas and switch to canvas view
3456
+ return render_canvas_items(canvas_items, colors, collapsed_ids), "canvas"
3457
+
3458
+
3459
+ # =============================================================================
3460
+ # DOWNLOAD DISPLAY INLINE CALLBACK - Download display inline content
3461
+ # =============================================================================
3462
+
3463
+ @app.callback(
3464
+ Output("file-download", "data", allow_duplicate=True),
3465
+ Input({"type": "download-display-btn", "index": ALL}, "n_clicks"),
3466
+ State({"type": "display-inline-data", "index": ALL}, "data"),
3467
+ prevent_initial_call=True
3468
+ )
3469
+ def download_display_inline_content(n_clicks_list, data_list):
3470
+ """Download a display_inline item when the download button is clicked."""
3471
+ import base64
3472
+
3473
+ ctx = callback_context
3474
+ # Check if any button was actually clicked
3475
+ if not n_clicks_list or not any(n_clicks_list):
3476
+ raise PreventUpdate
3477
+
3478
+ if not ctx.triggered:
3479
+ raise PreventUpdate
3480
+
3481
+ triggered = ctx.triggered[0]
3482
+ triggered_id = triggered["prop_id"]
3483
+
3484
+ # Parse the pattern-matching ID to get the index
3485
+ try:
3486
+ # Format: {"type":"download-display-btn","index":"abc123"}.n_clicks
3487
+ id_part = triggered_id.rsplit(".", 1)[0]
3488
+ id_dict = json.loads(id_part)
3489
+ clicked_index = id_dict.get("index")
3490
+ except (json.JSONDecodeError, KeyError, AttributeError):
3491
+ raise PreventUpdate
3492
+
3493
+ if not clicked_index:
3494
+ raise PreventUpdate
3495
+
3496
+ # Find the corresponding data
3497
+ display_data = None
3498
+ for data in data_list:
3499
+ if data and data.get("_item_id") == clicked_index:
3500
+ display_data = data
3501
+ break
3502
+
3503
+ if not display_data:
3504
+ raise PreventUpdate
3505
+
3506
+ display_type = display_data.get("display_type", "text")
3507
+ data = display_data.get("data")
3508
+ title = display_data.get("title", "download")
3509
+ filename = display_data.get("filename")
3510
+
3511
+ # Generate appropriate filename and content based on type
3512
+ if display_type == "image":
3513
+ mime_type = display_data.get("mime_type", "image/png")
3514
+ ext = mime_type.split("/")[-1]
3515
+ if ext == "jpeg":
3516
+ ext = "jpg"
3517
+ download_filename = filename or f"{title}.{ext}"
3518
+ return dict(
3519
+ content=data,
3520
+ filename=download_filename,
3521
+ base64=True
3522
+ )
3523
+
3524
+ elif display_type == "pdf":
3525
+ download_filename = filename or f"{title}.pdf"
3526
+ return dict(
3527
+ content=data,
3528
+ filename=download_filename,
3529
+ base64=True
3530
+ )
3531
+
3532
+ elif display_type == "html":
3533
+ download_filename = filename or f"{title}.html"
3534
+ return dict(
3535
+ content=data,
3536
+ filename=download_filename
3537
+ )
3538
+
3539
+ elif display_type == "dataframe":
3540
+ # Download as CSV
3541
+ csv_data = display_data.get("csv", {})
3542
+ columns = csv_data.get("columns", [])
3543
+ rows = csv_data.get("data", [])
3544
+
3545
+ # Build CSV content
3546
+ import csv
3547
+ import io
3548
+ output = io.StringIO()
3549
+ writer = csv.writer(output)
3550
+ writer.writerow(columns)
3551
+ for row in rows:
3552
+ writer.writerow([row.get(col, "") for col in columns])
3553
+ csv_content = output.getvalue()
3554
+
3555
+ download_filename = filename or f"{title}.csv"
3556
+ return dict(
3557
+ content=csv_content,
3558
+ filename=download_filename
3559
+ )
3560
+
3561
+ elif display_type == "json":
3562
+ json_content = json.dumps(data, indent=2) if isinstance(data, (dict, list)) else str(data)
3563
+ download_filename = filename or f"{title}.json"
3564
+ return dict(
3565
+ content=json_content,
3566
+ filename=download_filename
3567
+ )
3568
+
3569
+ elif display_type == "plotly":
3570
+ # Download Plotly chart as JSON
3571
+ json_content = json.dumps(data, indent=2) if isinstance(data, dict) else str(data)
3572
+ download_filename = filename or f"{title}.json"
3573
+ return dict(
3574
+ content=json_content,
3575
+ filename=download_filename
3576
+ )
3577
+
3578
+ else:
3579
+ # Text or unknown - download as .txt
3580
+ text_content = str(data) if data else ""
3581
+ download_filename = filename or f"{title}.txt"
3582
+ return dict(
3583
+ content=text_content,
3584
+ filename=download_filename
3585
+ )
3586
+
3587
+
3588
+ # =============================================================================
3589
+ # FULLSCREEN PREVIEW CALLBACK - Open HTML/PDF in fullscreen modal
3590
+ # =============================================================================
3591
+
3592
+ @app.callback(
3593
+ [Output("fullscreen-preview-modal", "opened"),
3594
+ Output("fullscreen-preview-modal", "title"),
3595
+ Output("fullscreen-preview-content", "children")],
3596
+ Input({"type": "fullscreen-btn", "index": ALL}, "n_clicks"),
3597
+ State({"type": "fullscreen-data", "index": ALL}, "data"),
3598
+ prevent_initial_call=True
3599
+ )
3600
+ def open_fullscreen_preview(n_clicks_list, data_list):
3601
+ """Open fullscreen modal for HTML/PDF preview."""
3602
+ ctx = callback_context
3603
+ if not n_clicks_list or not any(n_clicks_list):
3604
+ raise PreventUpdate
3605
+
3606
+ # Find which button was clicked
3607
+ triggered = ctx.triggered[0]
3608
+ triggered_id = triggered["prop_id"]
3609
+
3610
+ try:
3611
+ id_part = triggered_id.rsplit(".", 1)[0]
3612
+ id_dict = json.loads(id_part)
3613
+ clicked_index = id_dict.get("index")
3614
+ except (json.JSONDecodeError, KeyError, AttributeError):
3615
+ raise PreventUpdate
3616
+
3617
+ # Find the corresponding data
3618
+ fullscreen_data = None
3619
+ for i, data in enumerate(data_list):
3620
+ if data and n_clicks_list[i]:
3621
+ # Match by checking trigger
3622
+ btn_ids = ctx.inputs_list[0]
3623
+ if i < len(btn_ids) and btn_ids[i].get("id", {}).get("index") == clicked_index:
3624
+ fullscreen_data = data
3625
+ break
3626
+
3627
+ if not fullscreen_data:
3628
+ raise PreventUpdate
3629
+
3630
+ content_type = fullscreen_data.get("type")
3631
+ content = fullscreen_data.get("content")
3632
+ title = fullscreen_data.get("title", "Preview")
3633
+
3634
+ if content_type == "html":
3635
+ preview_content = html.Iframe(
3636
+ srcDoc=content,
3637
+ style={
3638
+ "width": "100%",
3639
+ "height": "100%",
3640
+ "border": "none",
3641
+ "backgroundColor": "white",
3642
+ }
3643
+ )
3644
+ elif content_type == "pdf":
3645
+ preview_content = html.Iframe(
3646
+ src=content,
3647
+ style={
3648
+ "width": "100%",
3649
+ "height": "100%",
3650
+ "border": "none",
3651
+ }
3652
+ )
3653
+ else:
3654
+ preview_content = html.Div("Unsupported content type")
3655
+
3656
+ return True, title, preview_content
3657
+
3658
+
2763
3659
  # =============================================================================
2764
3660
  # THEME TOGGLE CALLBACK - Using DMC 2.4 forceColorScheme
2765
3661
  # =============================================================================