cowork-dash 0.1.8__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
@@ -17,7 +17,15 @@ from typing import Optional, Dict, Any, List
17
17
  from dotenv import load_dotenv
18
18
  load_dotenv()
19
19
 
20
- from dash import Dash, html, Input, Output, State, callback_context, no_update, ALL
20
+ # Early pandas import to prevent circular import issues with Plotly's JSON serializer.
21
+ # Plotly lazily imports pandas and checks `obj is pd.NaT` which fails if pandas
22
+ # is partially initialized due to concurrent imports.
23
+ try:
24
+ import pandas
25
+ except (ImportError, AttributeError):
26
+ pass
27
+
28
+ from dash import Dash, html, dcc, Input, Output, State, callback_context, no_update, ALL
21
29
  from dash.exceptions import PreventUpdate
22
30
  import dash_mantine_components as dmc
23
31
  from dash_iconify import DashIconify
@@ -27,7 +35,7 @@ from .canvas import export_canvas_to_markdown, load_canvas_from_markdown
27
35
  from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
28
36
  from .components import (
29
37
  format_message, format_loading, format_thinking, format_todos_inline, render_canvas_items, format_tool_calls_inline,
30
- format_interrupt
38
+ format_interrupt, extract_display_inline_results, render_display_inline_result, extract_thinking_from_tool_calls
31
39
  )
32
40
  from .layout import create_layout as create_layout_component
33
41
  from .virtual_fs import get_session_manager
@@ -243,6 +251,7 @@ _agent_state = {
243
251
  "thinking": "",
244
252
  "todos": [],
245
253
  "tool_calls": [], # Current turn's tool calls (reset each turn)
254
+ "display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
246
255
  "canvas": load_canvas_from_markdown(WORKSPACE_ROOT) if not USE_VIRTUAL_FS else [], # Load from canvas.md if exists (physical FS only)
247
256
  "response": "",
248
257
  "error": None,
@@ -250,6 +259,7 @@ _agent_state = {
250
259
  "last_update": time.time(),
251
260
  "start_time": None, # Track when agent started for response time calculation
252
261
  "stop_requested": False, # Flag to request agent stop
262
+ "stop_event": None, # Threading event for immediate stop signaling
253
263
  }
254
264
  _agent_state_lock = threading.Lock()
255
265
 
@@ -267,11 +277,13 @@ def _get_default_agent_state() -> Dict[str, Any]:
267
277
  "thinking": "",
268
278
  "todos": [],
269
279
  "tool_calls": [],
280
+ "display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
270
281
  "canvas": [],
271
282
  "response": "",
272
283
  "error": None,
273
284
  "interrupt": None,
274
285
  "last_update": time.time(),
286
+ "stop_event": None, # Threading event for immediate stop signaling
275
287
  "start_time": None,
276
288
  "stop_requested": False,
277
289
  }
@@ -315,7 +327,9 @@ def _get_session_state_lock() -> threading.Lock:
315
327
 
316
328
 
317
329
  def request_agent_stop(session_id: Optional[str] = None):
318
- """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.
319
333
 
320
334
  Args:
321
335
  session_id: Session ID for virtual FS mode, None for physical FS mode.
@@ -325,10 +339,16 @@ def request_agent_stop(session_id: Optional[str] = None):
325
339
  with _session_agents_lock:
326
340
  state["stop_requested"] = True
327
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()
328
345
  else:
329
346
  with _agent_state_lock:
330
347
  _agent_state["stop_requested"] = True
331
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()
332
352
 
333
353
 
334
354
  def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None, session_id: Optional[str] = None):
@@ -359,6 +379,12 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
359
379
  current_state["running"] = False
360
380
  return
361
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
+
362
388
  # Track tool calls by their ID for updating status
363
389
  tool_call_map = {}
364
390
 
@@ -405,6 +431,17 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
405
431
  # Resume from interrupt
406
432
  from langgraph.types import Command
407
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()
408
445
  else:
409
446
  # Inject workspace context into the message if available
410
447
  if workspace_path:
@@ -415,14 +452,15 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
415
452
  agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
416
453
 
417
454
  for update in current_agent.stream(agent_input, stream_mode="updates", config=stream_config):
418
- # Check if stop was requested
419
- with state_lock:
420
- if current_state.get("stop_requested"):
421
- 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."
422
459
  current_state["running"] = False
423
460
  current_state["stop_requested"] = False
461
+ current_state["stop_event"] = None
424
462
  current_state["last_update"] = time.time()
425
- return
463
+ return
426
464
 
427
465
  # Check for interrupt
428
466
  if isinstance(update, dict) and "__interrupt__" in update:
@@ -431,6 +469,10 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
431
469
  with state_lock:
432
470
  current_state["interrupt"] = interrupt_data
433
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"
434
476
  current_state["last_update"] = time.time()
435
477
  return # Exit stream, wait for user to resume
436
478
 
@@ -444,14 +486,26 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
444
486
 
445
487
  # Capture AIMessage tool_calls
446
488
  if msg_type == 'AIMessage' and hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
447
- new_tool_calls = []
448
- for tc in last_msg.tool_calls:
449
- serialized = _serialize_tool_call(tc)
450
- tool_call_map[serialized["id"]] = serialized
451
- new_tool_calls.append(serialized)
452
-
453
489
  with state_lock:
454
- 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
+
455
509
  current_state["last_update"] = time.time()
456
510
 
457
511
  elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
@@ -480,10 +534,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
480
534
  content_lower.startswith("traceback")):
481
535
  status = "error"
482
536
 
483
- # Truncate result for display
484
- result_display = str(content)
485
- if len(result_display) > 1000:
486
- 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] + "..."
487
547
 
488
548
  _update_tool_call_result(tool_call_id, result_display, status)
489
549
 
@@ -688,6 +748,7 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
688
748
 
689
749
  with state_lock:
690
750
  current_state["running"] = False
751
+ current_state["stop_event"] = None # Clean up stop event
691
752
  current_state["last_update"] = time.time()
692
753
 
693
754
 
@@ -891,6 +952,7 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
891
952
 
892
953
  with state_lock:
893
954
  current_state["running"] = False
955
+ current_state["stop_event"] = None # Clean up stop event
894
956
  current_state["response"] = f"Action rejected{tool_info}: {reject_message}"
895
957
  current_state["last_update"] = time.time()
896
958
 
@@ -947,9 +1009,34 @@ def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
947
1009
  state["tool_calls"] = copy.deepcopy(current_state["tool_calls"])
948
1010
  state["todos"] = copy.deepcopy(current_state["todos"])
949
1011
  state["canvas"] = copy.deepcopy(current_state["canvas"])
1012
+ state["display_inline_items"] = copy.deepcopy(current_state.get("display_inline_items", []))
950
1013
  return state
951
1014
 
952
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
+
953
1040
  def reset_agent_state(session_id: Optional[str] = None):
954
1041
  """Reset agent state for a fresh session (thread-safe).
955
1042
 
@@ -971,8 +1058,11 @@ def reset_agent_state(session_id: Optional[str] = None):
971
1058
  current_state["thinking"] = ""
972
1059
  current_state["todos"] = []
973
1060
  current_state["tool_calls"] = []
1061
+ current_state["display_inline_items"] = []
974
1062
  current_state["response"] = ""
975
1063
  current_state["error"] = None
1064
+ current_state["stop_event"] = None
1065
+ current_state["stop_requested"] = False
976
1066
  current_state["interrupt"] = None
977
1067
  current_state["start_time"] = None
978
1068
  current_state["stop_requested"] = False
@@ -1087,8 +1177,10 @@ def display_initial_messages(history, theme, skip_render, session_initialized, s
1087
1177
  for msg in history:
1088
1178
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1089
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
1090
1181
  # Render tool calls stored with this message
1091
1182
  if msg.get("tool_calls"):
1183
+ # Show collapsed tool calls section first
1092
1184
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1093
1185
  if tool_calls_block:
1094
1186
  messages.append(tool_calls_block)
@@ -1097,6 +1189,18 @@ def display_initial_messages(history, theme, skip_render, session_initialized, s
1097
1189
  todos_block = format_todos_inline(msg["todos"], colors)
1098
1190
  if todos_block:
1099
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)
1100
1204
  return messages, False, True, new_session_id
1101
1205
 
1102
1206
 
@@ -1130,7 +1234,7 @@ def initialize_file_tree_for_session(session_initialized, session_id, current_wo
1130
1234
  current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
1131
1235
 
1132
1236
  # Build and render file tree
1133
- 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)
1134
1238
 
1135
1239
 
1136
1240
  # Chat callbacks
@@ -1165,8 +1269,9 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1165
1269
  is_new = (i == len(history) - 1)
1166
1270
  msg_response_time = m.get("response_time") if m["role"] == "assistant" else None
1167
1271
  messages.append(format_message(m["role"], m["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
1168
- # Render tool calls stored with this message
1272
+ # Order: tool calls -> todos -> thinking -> display inline items
1169
1273
  if m.get("tool_calls"):
1274
+ # Show collapsed tool calls section first
1170
1275
  tool_calls_block = format_tool_calls_inline(m["tool_calls"], colors)
1171
1276
  if tool_calls_block:
1172
1277
  messages.append(tool_calls_block)
@@ -1175,6 +1280,13 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1175
1280
  todos_block = format_todos_inline(m["todos"], colors)
1176
1281
  if todos_block:
1177
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)
1178
1290
 
1179
1291
  messages.append(format_loading(colors))
1180
1292
 
@@ -1220,14 +1332,18 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1220
1332
  history = history or []
1221
1333
  colors = get_colors(theme or "light")
1222
1334
 
1335
+ # Get display_inline items from agent state (bypasses LangGraph serialization)
1336
+ display_inline_items = state.get("display_inline_items", [])
1337
+
1223
1338
  def render_history_messages(history_items):
1224
- """Render all history items including tool calls and todos."""
1339
+ """Render all history items including tool calls, display_inline items, and todos."""
1225
1340
  messages = []
1226
1341
  for msg in history_items:
1227
1342
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1228
1343
  messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1229
- # Render tool calls stored with this message
1344
+ # Order: tool calls -> todos -> thinking -> display inline items
1230
1345
  if msg.get("tool_calls"):
1346
+ # Show collapsed tool calls section first
1231
1347
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1232
1348
  if tool_calls_block:
1233
1349
  messages.append(tool_calls_block)
@@ -1236,6 +1352,18 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1236
1352
  todos_block = format_todos_inline(msg["todos"], colors)
1237
1353
  if todos_block:
1238
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)
1239
1367
  return messages
1240
1368
 
1241
1369
  # Check for interrupt (human-in-the-loop)
@@ -1243,13 +1371,9 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1243
1371
  # Agent is paused waiting for user input
1244
1372
  messages = render_history_messages(history)
1245
1373
 
1246
- # Add current turn's thinking/tool_calls/todos before interrupt
1247
- if state["thinking"]:
1248
- thinking_block = format_thinking(state["thinking"], colors)
1249
- if thinking_block:
1250
- messages.append(thinking_block)
1251
-
1374
+ # Order: tool calls -> todos -> thinking -> display inline items
1252
1375
  if state.get("tool_calls"):
1376
+ # Show collapsed tool calls section first
1253
1377
  tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
1254
1378
  if tool_calls_block:
1255
1379
  messages.append(tool_calls_block)
@@ -1259,6 +1383,19 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1259
1383
  if todos_block:
1260
1384
  messages.append(todos_block)
1261
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
+
1262
1399
  # Add interrupt UI
1263
1400
  interrupt_block = format_interrupt(state["interrupt"], colors)
1264
1401
  if interrupt_block:
@@ -1274,15 +1411,20 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1274
1411
  if state.get("start_time"):
1275
1412
  response_time = time.time() - state["start_time"]
1276
1413
 
1277
- # 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
1278
1417
  if history:
1279
- # 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
1280
1419
  for i in range(len(history) - 1, -1, -1):
1281
1420
  if history[i]["role"] == "user":
1282
1421
  if state.get("tool_calls"):
1283
1422
  history[i]["tool_calls"] = state["tool_calls"]
1284
1423
  if state.get("todos"):
1285
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
1286
1428
  break
1287
1429
 
1288
1430
  # Add assistant response to history (with response time)
@@ -1295,12 +1437,13 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1295
1437
  history.append(assistant_msg)
1296
1438
 
1297
1439
  # Render all history (tool calls and todos are now part of history)
1440
+ # Order: tool calls -> todos -> thinking -> display inline items
1298
1441
  final_messages = []
1299
1442
  for i, msg in enumerate(history):
1300
1443
  is_new = (i >= len(history) - 1)
1301
1444
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1302
1445
  final_messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
1303
- # Render tool calls stored with this message
1446
+ # Show collapsed tool calls section first
1304
1447
  if msg.get("tool_calls"):
1305
1448
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1306
1449
  if tool_calls_block:
@@ -1310,21 +1453,35 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1310
1453
  todos_block = format_todos_inline(msg["todos"], colors)
1311
1454
  if todos_block:
1312
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)
1313
1475
 
1314
1476
  # Disable polling, set skip flag to prevent display_initial_messages from re-rendering
1315
1477
  return final_messages, history, True, True
1316
1478
  else:
1317
- # Agent still running - show loading with current thinking/tool_calls/todos
1479
+ # Agent still running - show loading with current tool_calls/todos/thinking
1318
1480
  messages = render_history_messages(history)
1319
1481
 
1320
- # Add current thinking if available
1321
- if state["thinking"]:
1322
- thinking_block = format_thinking(state["thinking"], colors)
1323
- if thinking_block:
1324
- messages.append(thinking_block)
1325
-
1326
- # Add current tool calls if available
1482
+ # Order: tool calls -> todos -> thinking -> display inline items
1327
1483
  if state.get("tool_calls"):
1484
+ # Show collapsed tool calls section first
1328
1485
  tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
1329
1486
  if tool_calls_block:
1330
1487
  messages.append(tool_calls_block)
@@ -1335,6 +1492,19 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1335
1492
  if todos_block:
1336
1493
  messages.append(todos_block)
1337
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
+
1338
1508
  # Add loading indicator
1339
1509
  messages.append(format_loading(colors))
1340
1510
 
@@ -1380,12 +1550,14 @@ def handle_stop_button(n_clicks, history, theme, session_id):
1380
1550
  request_agent_stop(session_id)
1381
1551
 
1382
1552
  # Render current messages with a stopping indicator
1553
+ # Order: tool calls -> todos -> thinking -> display inline items
1383
1554
  def render_history_messages(history):
1384
1555
  messages = []
1385
1556
  for i, msg in enumerate(history):
1386
1557
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1387
1558
  messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1388
1559
  if msg.get("tool_calls"):
1560
+ # Show collapsed tool calls section first
1389
1561
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1390
1562
  if tool_calls_block:
1391
1563
  messages.append(tool_calls_block)
@@ -1393,6 +1565,18 @@ def handle_stop_button(n_clicks, history, theme, session_id):
1393
1565
  todos_block = format_todos_inline(msg["todos"], colors)
1394
1566
  if todos_block:
1395
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)
1396
1580
  return messages
1397
1581
 
1398
1582
  messages = render_history_messages(history)
@@ -1468,12 +1652,13 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1468
1652
  resume_agent_from_interrupt(decision, action, session_id=session_id)
1469
1653
 
1470
1654
  # Show loading state while agent resumes
1655
+ # Order: tool calls -> todos -> thinking -> display inline items
1471
1656
  messages = []
1472
1657
  for msg in history:
1473
1658
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1474
1659
  messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1475
- # Render tool calls stored with this message
1476
1660
  if msg.get("tool_calls"):
1661
+ # Show collapsed tool calls section first
1477
1662
  tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1478
1663
  if tool_calls_block:
1479
1664
  messages.append(tool_calls_block)
@@ -1482,6 +1667,18 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1482
1667
  todos_block = format_todos_inline(msg["todos"], colors)
1483
1668
  if todos_block:
1484
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)
1485
1682
 
1486
1683
  messages.append(format_loading(colors))
1487
1684
 
@@ -1493,7 +1690,8 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1493
1690
  @app.callback(
1494
1691
  [Output({"type": "folder-children", "path": ALL}, "style"),
1495
1692
  Output({"type": "folder-icon", "path": ALL}, "style"),
1496
- Output({"type": "folder-children", "path": ALL}, "children")],
1693
+ Output({"type": "folder-children", "path": ALL}, "children"),
1694
+ Output("expanded-folders", "data")],
1497
1695
  Input({"type": "folder-icon", "path": ALL}, "n_clicks"),
1498
1696
  [State({"type": "folder-header", "path": ALL}, "id"),
1499
1697
  State({"type": "folder-header", "path": ALL}, "data-realpath"),
@@ -1503,16 +1701,18 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1503
1701
  State({"type": "folder-icon", "path": ALL}, "style"),
1504
1702
  State({"type": "folder-children", "path": ALL}, "children"),
1505
1703
  State("theme-store", "data"),
1506
- State("session-id", "data")],
1704
+ State("session-id", "data"),
1705
+ State("expanded-folders", "data")],
1507
1706
  prevent_initial_call=True
1508
1707
  )
1509
- def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme, session_id):
1708
+ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme, session_id, expanded_folders):
1510
1709
  """Toggle folder expansion and lazy load contents if needed."""
1511
1710
  ctx = callback_context
1512
1711
  if not ctx.triggered or not any(n_clicks):
1513
1712
  raise PreventUpdate
1514
1713
 
1515
1714
  colors = get_colors(theme or "light")
1715
+ expanded_folders = expanded_folders or []
1516
1716
 
1517
1717
  # Get workspace for this session (virtual or physical)
1518
1718
  workspace_root = get_workspace_for_session(session_id)
@@ -1538,6 +1738,9 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1538
1738
  new_icon_styles = []
1539
1739
  new_children_content = []
1540
1740
 
1741
+ # Track whether we're expanding or collapsing the clicked folder
1742
+ will_expand = None
1743
+
1541
1744
  # Process all folder-children elements
1542
1745
  for i, child_id in enumerate(children_ids):
1543
1746
  path = child_id["path"]
@@ -1547,6 +1750,7 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1547
1750
  if path == clicked_path:
1548
1751
  # Toggle this folder
1549
1752
  is_expanded = current_style.get("display") != "none"
1753
+ will_expand = not is_expanded
1550
1754
  new_children_styles.append({"display": "none" if is_expanded else "block"})
1551
1755
 
1552
1756
  # If expanding and content is just "Loading...", load the actual contents
@@ -1560,7 +1764,9 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1560
1764
  folder_items = load_folder_contents(folder_rel_path, workspace_root)
1561
1765
  loaded_content = render_file_tree(folder_items, colors, STYLES,
1562
1766
  level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
1563
- parent_path=folder_rel_path)
1767
+ parent_path=folder_rel_path,
1768
+ expanded_folders=expanded_folders,
1769
+ workspace_root=workspace_root)
1564
1770
  new_children_content.append(loaded_content if loaded_content else current_content)
1565
1771
  except Exception as e:
1566
1772
  print(f"Error loading folder {folder_rel_path}: {e}")
@@ -1597,14 +1803,23 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1597
1803
  else:
1598
1804
  new_icon_styles.append(current_icon_style)
1599
1805
 
1600
- return new_children_styles, new_icon_styles, new_children_content
1806
+ # Update expanded folders list
1807
+ new_expanded_folders = list(expanded_folders)
1808
+ if will_expand is not None:
1809
+ if will_expand and clicked_path not in new_expanded_folders:
1810
+ new_expanded_folders.append(clicked_path)
1811
+ elif not will_expand and clicked_path in new_expanded_folders:
1812
+ new_expanded_folders.remove(clicked_path)
1813
+
1814
+ return new_children_styles, new_icon_styles, new_children_content, new_expanded_folders
1601
1815
 
1602
1816
 
1603
1817
  # Enter folder callback - triggered by double-clicking folder name (changes workspace root)
1604
1818
  @app.callback(
1605
1819
  [Output("current-workspace-path", "data"),
1606
1820
  Output("workspace-breadcrumb", "children"),
1607
- Output("file-tree", "children", allow_duplicate=True)],
1821
+ Output("file-tree", "children", allow_duplicate=True),
1822
+ Output("expanded-folders", "data", allow_duplicate=True)],
1608
1823
  [Input({"type": "folder-select", "path": ALL}, "n_clicks"),
1609
1824
  Input("breadcrumb-root", "n_clicks"),
1610
1825
  Input({"type": "breadcrumb-segment", "index": ALL}, "n_clicks")],
@@ -1720,13 +1935,14 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1720
1935
  else:
1721
1936
  workspace_full_path = workspace_root / new_path if new_path else workspace_root
1722
1937
 
1723
- # Render new file tree
1938
+ # Render new file tree (reset expanded folders when navigating)
1724
1939
  file_tree = render_file_tree(
1725
1940
  build_file_tree(workspace_full_path, workspace_full_path),
1726
- colors, STYLES
1941
+ colors, STYLES,
1942
+ workspace_root=workspace_root
1727
1943
  )
1728
1944
 
1729
- return new_path, breadcrumb_children, file_tree
1945
+ return new_path, breadcrumb_children, file_tree, [] # Reset expanded folders
1730
1946
 
1731
1947
 
1732
1948
  # File click - open modal
@@ -1805,23 +2021,342 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme, session_id):
1805
2021
  colors = get_colors(theme or "light")
1806
2022
  content, is_text, error = read_file_content(workspace_root, file_path)
1807
2023
  filename = Path(file_path).name
1808
-
1809
- if is_text and content:
1810
- modal_content = html.Pre(
1811
- content,
1812
- style={
1813
- "background": colors["bg_tertiary"],
1814
- "padding": "16px",
1815
- "fontSize": "12px",
1816
- "fontFamily": "'IBM Plex Mono', monospace",
1817
- "overflow": "auto",
1818
- "maxHeight": "60vh",
1819
- "whiteSpace": "pre-wrap",
1820
- "wordBreak": "break-word",
1821
- "margin": "0",
1822
- "color": colors["text_primary"],
1823
- }
1824
- )
2024
+ file_ext = Path(file_path).suffix.lower()
2025
+
2026
+ # Define file type categories for binary previews
2027
+ image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp'}
2028
+ pdf_exts = {'.pdf'}
2029
+
2030
+ # Check for binary preview types first
2031
+ if file_ext in image_exts | pdf_exts:
2032
+ b64, _, mime = get_file_download_data(workspace_root, file_path)
2033
+ if b64:
2034
+ data_url = f"data:{mime};base64,{b64}"
2035
+
2036
+ if file_ext in image_exts:
2037
+ # Image preview
2038
+ modal_content = html.Div([
2039
+ html.Img(
2040
+ src=data_url,
2041
+ style={
2042
+ "maxWidth": "100%",
2043
+ "maxHeight": "80vh",
2044
+ "display": "block",
2045
+ "margin": "0 auto",
2046
+ "borderRadius": "4px",
2047
+ }
2048
+ )
2049
+ ], style={"textAlign": "center"})
2050
+
2051
+ elif file_ext in pdf_exts:
2052
+ # PDF preview via embed
2053
+ modal_content = html.Embed(
2054
+ src=data_url,
2055
+ type="application/pdf",
2056
+ style={
2057
+ "width": "100%",
2058
+ "height": "80vh",
2059
+ "borderRadius": "4px",
2060
+ }
2061
+ )
2062
+ else:
2063
+ # Failed to read binary file
2064
+ modal_content = html.Div([
2065
+ html.P("Failed to load file preview", style={
2066
+ "color": colors["text_muted"],
2067
+ "textAlign": "center",
2068
+ "padding": "40px",
2069
+ }),
2070
+ html.P("Click Download to save the file.", style={
2071
+ "color": colors["text_muted"],
2072
+ "textAlign": "center",
2073
+ "fontSize": "13px",
2074
+ })
2075
+ ])
2076
+
2077
+ elif is_text and content:
2078
+ # HTML files get rendered preview
2079
+ if file_ext in ('.html', '.htm'):
2080
+ modal_content = html.Div([
2081
+ # Tab buttons for switching views
2082
+ html.Div([
2083
+ html.Button("Preview", id="html-preview-tab", n_clicks=0,
2084
+ className="html-tab-btn html-tab-active",
2085
+ style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
2086
+ "borderRadius": "4px", "cursor": "pointer",
2087
+ "background": colors["accent"], "color": "#fff"}),
2088
+ html.Button("Source", id="html-source-tab", n_clicks=0,
2089
+ className="html-tab-btn",
2090
+ style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
2091
+ "borderRadius": "4px", "cursor": "pointer",
2092
+ "background": "transparent", "color": colors["text_primary"]}),
2093
+ ], style={"marginBottom": "12px", "display": "flex"}),
2094
+ # Preview iframe (default visible)
2095
+ html.Iframe(
2096
+ srcDoc=content,
2097
+ style={
2098
+ "width": "100%",
2099
+ "height": "80vh",
2100
+ "border": f"1px solid {colors['border']}",
2101
+ "borderRadius": "4px",
2102
+ "background": "#fff",
2103
+ },
2104
+ id="html-preview-frame"
2105
+ ),
2106
+ # Source code (hidden by default)
2107
+ html.Pre(
2108
+ content,
2109
+ id="html-source-code",
2110
+ style={
2111
+ "display": "none",
2112
+ "background": colors["bg_tertiary"],
2113
+ "padding": "16px",
2114
+ "fontSize": "12px",
2115
+ "fontFamily": "'IBM Plex Mono', monospace",
2116
+ "overflow": "auto",
2117
+ "maxHeight": "80vh",
2118
+ "whiteSpace": "pre-wrap",
2119
+ "wordBreak": "break-word",
2120
+ "margin": "0",
2121
+ "color": colors["text_primary"],
2122
+ "border": f"1px solid {colors['border']}",
2123
+ "borderRadius": "4px",
2124
+ }
2125
+ )
2126
+ ])
2127
+ elif file_ext == '.json':
2128
+ # Try to parse as Plotly JSON figure
2129
+ plotly_figure = None
2130
+ try:
2131
+ data = json.loads(content)
2132
+ # Check if it looks like a Plotly figure (has 'data' key with list)
2133
+ if isinstance(data, dict) and 'data' in data and isinstance(data['data'], list):
2134
+ plotly_figure = data
2135
+ except (json.JSONDecodeError, KeyError):
2136
+ pass
2137
+
2138
+ if plotly_figure:
2139
+ # Render as interactive Plotly chart with source toggle
2140
+ modal_content = html.Div([
2141
+ # Tab buttons for switching views
2142
+ html.Div([
2143
+ html.Button("Chart", id="html-preview-tab", n_clicks=0,
2144
+ className="html-tab-btn html-tab-active",
2145
+ style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
2146
+ "borderRadius": "4px", "cursor": "pointer",
2147
+ "background": colors["accent"], "color": "#fff"}),
2148
+ html.Button("JSON", id="html-source-tab", n_clicks=0,
2149
+ className="html-tab-btn",
2150
+ style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
2151
+ "borderRadius": "4px", "cursor": "pointer",
2152
+ "background": "transparent", "color": colors["text_primary"]}),
2153
+ ], style={"marginBottom": "12px", "display": "flex"}),
2154
+ # Plotly chart (default visible)
2155
+ html.Div([
2156
+ dcc.Graph(
2157
+ figure=plotly_figure,
2158
+ style={"height": "75vh"},
2159
+ config={"displayModeBar": True, "responsive": True}
2160
+ )
2161
+ ], id="html-preview-frame", style={
2162
+ "border": f"1px solid {colors['border']}",
2163
+ "borderRadius": "4px",
2164
+ "background": "#fff",
2165
+ }),
2166
+ # JSON source (hidden by default)
2167
+ html.Pre(
2168
+ json.dumps(plotly_figure, indent=2),
2169
+ id="html-source-code",
2170
+ style={
2171
+ "display": "none",
2172
+ "background": colors["bg_tertiary"],
2173
+ "padding": "16px",
2174
+ "fontSize": "12px",
2175
+ "fontFamily": "'IBM Plex Mono', monospace",
2176
+ "overflow": "auto",
2177
+ "maxHeight": "80vh",
2178
+ "whiteSpace": "pre-wrap",
2179
+ "wordBreak": "break-word",
2180
+ "margin": "0",
2181
+ "color": colors["text_primary"],
2182
+ "border": f"1px solid {colors['border']}",
2183
+ "borderRadius": "4px",
2184
+ }
2185
+ )
2186
+ ])
2187
+ else:
2188
+ # Regular JSON - show formatted
2189
+ try:
2190
+ formatted = json.dumps(json.loads(content), indent=2)
2191
+ except json.JSONDecodeError:
2192
+ formatted = content
2193
+ modal_content = html.Pre(
2194
+ formatted,
2195
+ style={
2196
+ "background": colors["bg_tertiary"],
2197
+ "padding": "16px",
2198
+ "fontSize": "12px",
2199
+ "fontFamily": "'IBM Plex Mono', monospace",
2200
+ "overflow": "auto",
2201
+ "maxHeight": "80vh",
2202
+ "whiteSpace": "pre-wrap",
2203
+ "wordBreak": "break-word",
2204
+ "margin": "0",
2205
+ "color": colors["text_primary"],
2206
+ }
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
+ ])
2343
+ else:
2344
+ # Regular text files
2345
+ modal_content = html.Pre(
2346
+ content,
2347
+ style={
2348
+ "background": colors["bg_tertiary"],
2349
+ "padding": "16px",
2350
+ "fontSize": "12px",
2351
+ "fontFamily": "'IBM Plex Mono', monospace",
2352
+ "overflow": "auto",
2353
+ "maxHeight": "80vh",
2354
+ "whiteSpace": "pre-wrap",
2355
+ "wordBreak": "break-word",
2356
+ "margin": "0",
2357
+ "color": colors["text_primary"],
2358
+ }
2359
+ )
1825
2360
  else:
1826
2361
  modal_content = html.Div([
1827
2362
  html.P(error or "Cannot display file", style={
@@ -1870,6 +2405,142 @@ def download_from_modal(n_clicks, file_path, session_id):
1870
2405
  return dict(content=b64, filename=filename, base64=True, type=mime)
1871
2406
 
1872
2407
 
2408
+ # HTML preview/source tab switching
2409
+ @app.callback(
2410
+ [Output("html-preview-frame", "style"),
2411
+ Output("html-source-code", "style"),
2412
+ Output("html-preview-tab", "style"),
2413
+ Output("html-source-tab", "style")],
2414
+ [Input("html-preview-tab", "n_clicks"),
2415
+ Input("html-source-tab", "n_clicks")],
2416
+ [State("theme-store", "data"),
2417
+ State("html-preview-frame", "style"),
2418
+ State("html-source-code", "style")],
2419
+ prevent_initial_call=True
2420
+ )
2421
+ def toggle_html_view(preview_clicks, source_clicks, theme, current_preview_style, current_source_style):
2422
+ """Toggle between HTML preview and source code view."""
2423
+ ctx = callback_context
2424
+ if not ctx.triggered:
2425
+ raise PreventUpdate
2426
+
2427
+ colors = get_colors(theme or "light")
2428
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2429
+
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({
2437
+ "background": colors["bg_tertiary"],
2438
+ "color": colors["text_primary"],
2439
+ "border": f"1px solid {colors['border']}",
2440
+ })
2441
+
2442
+ active_btn_style = {
2443
+ "marginRight": "8px", "padding": "6px 12px", "border": "none",
2444
+ "borderRadius": "4px", "cursor": "pointer",
2445
+ "background": colors["accent"], "color": "#fff"
2446
+ }
2447
+ inactive_btn_style = {
2448
+ "padding": "6px 12px", "border": f"1px solid {colors['border']}",
2449
+ "borderRadius": "4px", "cursor": "pointer",
2450
+ "background": "transparent", "color": colors["text_primary"]
2451
+ }
2452
+
2453
+ if triggered_id == "html-source-tab":
2454
+ # Show source, hide preview
2455
+ preview_frame_style["display"] = "none"
2456
+ source_code_style["display"] = "block"
2457
+ return preview_frame_style, source_code_style, {**inactive_btn_style, "marginRight": "8px"}, active_btn_style
2458
+ else:
2459
+ # Show preview, hide source (default)
2460
+ preview_frame_style["display"] = "block"
2461
+ source_code_style["display"] = "none"
2462
+ return preview_frame_style, source_code_style, active_btn_style, {**inactive_btn_style}
2463
+
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
+
1873
2544
  # Open terminal
1874
2545
  @app.callback(
1875
2546
  Output("open-terminal-btn", "n_clicks"),
@@ -1922,13 +2593,15 @@ def open_terminal(n_clicks):
1922
2593
  [State("current-workspace-path", "data"),
1923
2594
  State("theme-store", "data"),
1924
2595
  State("collapsed-canvas-items", "data"),
1925
- State("session-id", "data")],
2596
+ State("session-id", "data"),
2597
+ State("expanded-folders", "data")],
1926
2598
  prevent_initial_call=True
1927
2599
  )
1928
- def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id):
2600
+ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id, expanded_folders):
1929
2601
  """Refresh both file tree and canvas content."""
1930
2602
  colors = get_colors(theme or "light")
1931
2603
  collapsed_ids = collapsed_ids or []
2604
+ expanded_folders = expanded_folders or []
1932
2605
 
1933
2606
  # Get workspace for this session (virtual or physical)
1934
2607
  workspace_root = get_workspace_for_session(session_id)
@@ -1939,8 +2612,8 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_i
1939
2612
  else:
1940
2613
  current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
1941
2614
 
1942
- # Refresh file tree for current workspace
1943
- file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
2615
+ # Refresh file tree for current workspace, preserving 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)
1944
2617
 
1945
2618
  # Re-render canvas from current in-memory state (don't reload from file)
1946
2619
  # This preserves canvas items that may not have been exported to .canvas/canvas.md yet
@@ -1960,15 +2633,17 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_i
1960
2633
  [State("file-upload-sidebar", "filename"),
1961
2634
  State("current-workspace-path", "data"),
1962
2635
  State("theme-store", "data"),
1963
- State("session-id", "data")],
2636
+ State("session-id", "data"),
2637
+ State("expanded-folders", "data")],
1964
2638
  prevent_initial_call=True
1965
2639
  )
1966
- def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id):
2640
+ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id, expanded_folders):
1967
2641
  """Handle file uploads from sidebar button to current workspace."""
1968
2642
  if not contents:
1969
2643
  raise PreventUpdate
1970
2644
 
1971
2645
  colors = get_colors(theme or "light")
2646
+ expanded_folders = expanded_folders or []
1972
2647
 
1973
2648
  # Get workspace for this session (virtual or physical)
1974
2649
  workspace_root = get_workspace_for_session(session_id)
@@ -1991,7 +2666,7 @@ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session
1991
2666
  except Exception as e:
1992
2667
  print(f"Upload error: {e}")
1993
2668
 
1994
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
2669
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
1995
2670
 
1996
2671
 
1997
2672
  # Create folder modal - open
@@ -2034,15 +2709,17 @@ def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_op
2034
2709
  [State("new-folder-name", "value"),
2035
2710
  State("current-workspace-path", "data"),
2036
2711
  State("theme-store", "data"),
2037
- State("session-id", "data")],
2712
+ State("session-id", "data"),
2713
+ State("expanded-folders", "data")],
2038
2714
  prevent_initial_call=True
2039
2715
  )
2040
- def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
2716
+ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id, expanded_folders):
2041
2717
  """Create a new folder in the current workspace directory."""
2042
2718
  if not n_clicks:
2043
2719
  raise PreventUpdate
2044
2720
 
2045
2721
  colors = get_colors(theme or "light")
2722
+ expanded_folders = expanded_folders or []
2046
2723
 
2047
2724
  if not folder_name or not folder_name.strip():
2048
2725
  return no_update, "Please enter a folder name", no_update
@@ -2070,7 +2747,7 @@ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
2070
2747
 
2071
2748
  try:
2072
2749
  folder_path.mkdir(parents=True, exist_ok=False)
2073
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES), "", ""
2750
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root), "", ""
2074
2751
  except Exception as e:
2075
2752
  return no_update, f"Error creating folder: {e}", no_update
2076
2753
 
@@ -2156,15 +2833,17 @@ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids, session
2156
2833
  [State("current-workspace-path", "data"),
2157
2834
  State("theme-store", "data"),
2158
2835
  State("session-id", "data"),
2159
- State("sidebar-view-toggle", "value")],
2836
+ State("sidebar-view-toggle", "value"),
2837
+ State("expanded-folders", "data")],
2160
2838
  prevent_initial_call=True
2161
2839
  )
2162
- def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, view_value):
2840
+ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, view_value, expanded_folders):
2163
2841
  """Refresh file tree during agent execution to show newly created files.
2164
2842
 
2165
2843
  This callback runs on each poll interval and refreshes the file tree
2166
2844
  so that files created by the agent are visible in real-time.
2167
2845
  Only updates when viewing files (not canvas).
2846
+ Preserves expanded folder state across refreshes.
2168
2847
  """
2169
2848
  # Only refresh when viewing files panel
2170
2849
  if view_value != "files":
@@ -2180,6 +2859,7 @@ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, vie
2180
2859
  raise PreventUpdate
2181
2860
 
2182
2861
  colors = get_colors(theme or "light")
2862
+ expanded_folders = expanded_folders or []
2183
2863
 
2184
2864
  # Get workspace for this session (virtual or physical)
2185
2865
  workspace_root = get_workspace_for_session(session_id)
@@ -2190,8 +2870,8 @@ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, vie
2190
2870
  else:
2191
2871
  current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
2192
2872
 
2193
- # Refresh file tree
2194
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
2873
+ # Refresh file tree, preserving expanded folder state
2874
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
2195
2875
 
2196
2876
 
2197
2877
  # Open clear canvas confirmation modal
@@ -2477,6 +3157,133 @@ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, co
2477
3157
  raise PreventUpdate
2478
3158
 
2479
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
+
2480
3287
  # =============================================================================
2481
3288
  # THEME TOGGLE CALLBACK - Using DMC 2.4 forceColorScheme
2482
3289
  # =============================================================================