cowork-dash 0.2.0__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, extract_display_inline_results, render_display_inline_result, extract_thinking_from_tool_calls
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,7 @@ _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": ...}
254
256
  "display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
255
257
  "canvas": load_canvas_from_markdown(WORKSPACE_ROOT) if not USE_VIRTUAL_FS else [], # Load from canvas.md if exists (physical FS only)
256
258
  "response": "",
@@ -277,6 +279,7 @@ def _get_default_agent_state() -> Dict[str, Any]:
277
279
  "thinking": "",
278
280
  "todos": [],
279
281
  "tool_calls": [],
282
+ "content_items": [], # Ordered list of content: {"type": "text"|"thinking"|"tool_calls", "content": ...}
280
283
  "display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
281
284
  "canvas": [],
282
285
  "response": "",
@@ -484,29 +487,59 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
484
487
  last_msg = msgs[-1] if isinstance(msgs, list) else msgs
485
488
  msg_type = last_msg.__class__.__name__ if hasattr(last_msg, '__class__') else None
486
489
 
487
- # Capture AIMessage tool_calls
488
- if msg_type == 'AIMessage' and hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
489
- with state_lock:
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
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()
508
507
 
509
- 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()
510
543
 
511
544
  elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
512
545
  # Update tool call status when we get the result
@@ -560,9 +593,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
560
593
  elif isinstance(content, dict):
561
594
  thinking_text = content.get('reflection', str(content))
562
595
 
563
- # Update state immediately
596
+ # Update state immediately - add to ordered content_items
564
597
  with state_lock:
565
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
+ })
566
606
  current_state["last_update"] = time.time()
567
607
 
568
608
  elif last_msg.name == 'write_todos':
@@ -716,26 +756,6 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
716
756
  except Exception as e:
717
757
  print(f"Failed to export canvas: {e}")
718
758
 
719
- elif hasattr(last_msg, 'content'):
720
- content = last_msg.content
721
- response_text = ""
722
- if isinstance(content, str):
723
- response_text = re.sub(
724
- r"\{'id':\s*'[^']+',\s*'input':\s*\{.*?\},\s*'name':\s*'[^']+',\s*'type':\s*'tool_use'\}",
725
- '', content, flags=re.DOTALL
726
- ).strip()
727
- elif isinstance(content, list):
728
- text_parts = [
729
- block.get("text", "") if isinstance(block, dict) else str(block)
730
- for block in content
731
- ]
732
- response_text = " ".join(text_parts).strip()
733
-
734
- if response_text:
735
- with state_lock:
736
- current_state["response"] = response_text
737
- current_state["last_update"] = time.time()
738
-
739
759
  except Exception as e:
740
760
  with state_lock:
741
761
  current_state["error"] = str(e)
@@ -875,6 +895,7 @@ def call_agent(message: str, resume_data: Dict = None, workspace_path: str = Non
875
895
  "thinking": "",
876
896
  "todos": [],
877
897
  "tool_calls": [], # Reset tool calls for this turn
898
+ "content_items": [], # Ordered list of content items
878
899
  "canvas": existing_canvas, # Preserve existing canvas
879
900
  "response": "",
880
901
  "error": None,
@@ -1009,6 +1030,7 @@ def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
1009
1030
  state["tool_calls"] = copy.deepcopy(current_state["tool_calls"])
1010
1031
  state["todos"] = copy.deepcopy(current_state["todos"])
1011
1032
  state["canvas"] = copy.deepcopy(current_state["canvas"])
1033
+ state["content_items"] = copy.deepcopy(current_state.get("content_items", []))
1012
1034
  state["display_inline_items"] = copy.deepcopy(current_state.get("display_inline_items", []))
1013
1035
  return state
1014
1036
 
@@ -1034,6 +1056,13 @@ def push_display_inline_item(item: Dict[str, Any], session_id: Optional[str] = N
1034
1056
  if "display_inline_items" not in current_state:
1035
1057
  current_state["display_inline_items"] = []
1036
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
+ })
1037
1066
  current_state["last_update"] = time.time()
1038
1067
 
1039
1068
 
@@ -1058,6 +1087,7 @@ def reset_agent_state(session_id: Optional[str] = None):
1058
1087
  current_state["thinking"] = ""
1059
1088
  current_state["todos"] = []
1060
1089
  current_state["tool_calls"] = []
1090
+ current_state["content_items"] = []
1061
1091
  current_state["display_inline_items"] = []
1062
1092
  current_state["response"] = ""
1063
1093
  current_state["error"] = None
@@ -1176,31 +1206,49 @@ def display_initial_messages(history, theme, skip_render, session_initialized, s
1176
1206
  messages = []
1177
1207
  for msg in history:
1178
1208
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
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
1181
- # Render tool calls stored with this message
1182
- if msg.get("tool_calls"):
1183
- # Show collapsed tool calls section first
1184
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1185
- if tool_calls_block:
1186
- messages.append(tool_calls_block)
1187
- # Render todos stored with this message
1188
- if msg.get("todos"):
1189
- todos_block = format_todos_inline(msg["todos"], colors)
1190
- if todos_block:
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)
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
+
1204
1252
  return messages, False, True, new_session_id
1205
1253
 
1206
1254
 
@@ -1268,25 +1316,41 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
1268
1316
  for i, m in enumerate(history):
1269
1317
  is_new = (i == len(history) - 1)
1270
1318
  msg_response_time = m.get("response_time") if m["role"] == "assistant" else None
1271
- messages.append(format_message(m["role"], m["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
1272
- # Order: tool calls -> todos -> thinking -> display inline items
1273
- if m.get("tool_calls"):
1274
- # Show collapsed tool calls section first
1275
- tool_calls_block = format_tool_calls_inline(m["tool_calls"], colors)
1276
- if tool_calls_block:
1277
- messages.append(tool_calls_block)
1278
- # Render todos stored with this message
1279
- if m.get("todos"):
1280
- todos_block = format_todos_inline(m["todos"], colors)
1281
- if todos_block:
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)
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))
1290
1354
 
1291
1355
  messages.append(format_loading(colors))
1292
1356
 
@@ -1340,30 +1404,49 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1340
1404
  messages = []
1341
1405
  for msg in history_items:
1342
1406
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1343
- messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1344
- # Order: tool calls -> todos -> thinking -> display inline items
1345
- if msg.get("tool_calls"):
1346
- # Show collapsed tool calls section first
1347
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1348
- if tool_calls_block:
1349
- messages.append(tool_calls_block)
1350
- # Render todos stored with this message
1351
- if msg.get("todos"):
1352
- todos_block = format_todos_inline(msg["todos"], colors)
1353
- if todos_block:
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)
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
+
1367
1450
  return messages
1368
1451
 
1369
1452
  # Check for interrupt (human-in-the-loop)
@@ -1371,30 +1454,37 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1371
1454
  # Agent is paused waiting for user input
1372
1455
  messages = render_history_messages(history)
1373
1456
 
1374
- # Order: tool calls -> todos -> thinking -> display inline items
1457
+ # Show collapsed tool calls section
1375
1458
  if state.get("tool_calls"):
1376
- # Show collapsed tool calls section first
1377
1459
  tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
1378
1460
  if tool_calls_block:
1379
1461
  messages.append(tool_calls_block)
1380
1462
 
1463
+ # Render todos
1381
1464
  if state["todos"]:
1382
1465
  todos_block = format_todos_inline(state["todos"], colors)
1383
1466
  if todos_block:
1384
1467
  messages.append(todos_block)
1385
1468
 
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)
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)
1398
1488
 
1399
1489
  # Add interrupt UI
1400
1490
  interrupt_block = format_interrupt(state["interrupt"], colors)
@@ -1411,60 +1501,36 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1411
1501
  if state.get("start_time"):
1412
1502
  response_time = time.time() - state["start_time"]
1413
1503
 
1414
- # Agent finished - store tool calls, todos, and display_inline items with the USER message
1504
+ # Agent finished - store tool calls, todos, content_items, and display_inline items with the USER message
1415
1505
  # (they appear after user msg in the UI)
1416
1506
  saved_display_inline_items = False
1417
1507
  if history:
1418
- # Find the last user message and attach tool calls, todos, and display_inline items to it
1508
+ # Find the last user message and attach tool calls, todos, content_items, and display_inline items to it
1419
1509
  for i in range(len(history) - 1, -1, -1):
1420
1510
  if history[i]["role"] == "user":
1421
1511
  if state.get("tool_calls"):
1422
1512
  history[i]["tool_calls"] = state["tool_calls"]
1423
1513
  if state.get("todos"):
1424
1514
  history[i]["todos"] = state["todos"]
1515
+ if state.get("content_items"):
1516
+ history[i]["content_items"] = state["content_items"]
1425
1517
  if display_inline_items:
1426
1518
  history[i]["display_inline_items"] = display_inline_items
1427
1519
  saved_display_inline_items = True
1428
1520
  break
1429
1521
 
1430
1522
  # Add assistant response to history (with response time)
1523
+ # Note: In the new model, content may be empty if content_items is used
1431
1524
  assistant_msg = {
1432
1525
  "role": "assistant",
1433
- "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']}"),
1434
1527
  "response_time": response_time,
1435
1528
  }
1436
1529
 
1437
1530
  history.append(assistant_msg)
1438
1531
 
1439
- # Render all history (tool calls and todos are now part of history)
1440
- # Order: tool calls -> todos -> thinking -> display inline items
1441
- final_messages = []
1442
- for i, msg in enumerate(history):
1443
- is_new = (i >= len(history) - 1)
1444
- msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1445
- final_messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
1446
- # Show collapsed tool calls section first
1447
- if msg.get("tool_calls"):
1448
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1449
- if tool_calls_block:
1450
- final_messages.append(tool_calls_block)
1451
- # Render todos stored with this message
1452
- if msg.get("todos"):
1453
- todos_block = format_todos_inline(msg["todos"], colors)
1454
- if todos_block:
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)
1532
+ # Render all history using render_history_messages (which handles content_items)
1533
+ final_messages = render_history_messages(history)
1468
1534
 
1469
1535
  # Render any NEW queued display_inline items only if not already saved to history
1470
1536
  # (avoids duplicate rendering)
@@ -1476,34 +1542,40 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
1476
1542
  # Disable polling, set skip flag to prevent display_initial_messages from re-rendering
1477
1543
  return final_messages, history, True, True
1478
1544
  else:
1479
- # Agent still running - show loading with current tool_calls/todos/thinking
1545
+ # Agent still running - show loading with current tool_calls/todos/content_items
1480
1546
  messages = render_history_messages(history)
1481
1547
 
1482
- # Order: tool calls -> todos -> thinking -> display inline items
1548
+ # Show collapsed tool calls section
1483
1549
  if state.get("tool_calls"):
1484
- # Show collapsed tool calls section first
1485
1550
  tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
1486
1551
  if tool_calls_block:
1487
1552
  messages.append(tool_calls_block)
1488
1553
 
1489
- # Add current todos if available
1554
+ # Render todos
1490
1555
  if state["todos"]:
1491
1556
  todos_block = format_todos_inline(state["todos"], colors)
1492
1557
  if todos_block:
1493
1558
  messages.append(todos_block)
1494
1559
 
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)
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)
1507
1579
 
1508
1580
  # Add loading indicator
1509
1581
  messages.append(format_loading(colors))
@@ -1550,36 +1622,55 @@ def handle_stop_button(n_clicks, history, theme, session_id):
1550
1622
  request_agent_stop(session_id)
1551
1623
 
1552
1624
  # Render current messages with a stopping indicator
1553
- # Order: tool calls -> todos -> thinking -> display inline items
1554
- def render_history_messages(history):
1625
+ def render_history_messages_local(history):
1555
1626
  messages = []
1556
1627
  for i, msg in enumerate(history):
1557
1628
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1558
- messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1559
- if msg.get("tool_calls"):
1560
- # Show collapsed tool calls section first
1561
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1562
- if tool_calls_block:
1563
- messages.append(tool_calls_block)
1564
- if msg.get("todos"):
1565
- todos_block = format_todos_inline(msg["todos"], colors)
1566
- if todos_block:
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)
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
+
1580
1671
  return messages
1581
1672
 
1582
- messages = render_history_messages(history)
1673
+ messages = render_history_messages_local(history)
1583
1674
 
1584
1675
  # Add stopping message
1585
1676
  messages.append(html.Div([
@@ -1652,33 +1743,50 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1652
1743
  resume_agent_from_interrupt(decision, action, session_id=session_id)
1653
1744
 
1654
1745
  # Show loading state while agent resumes
1655
- # Order: tool calls -> todos -> thinking -> display inline items
1656
1746
  messages = []
1657
1747
  for msg in history:
1658
1748
  msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1659
- messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1660
- if msg.get("tool_calls"):
1661
- # Show collapsed tool calls section first
1662
- tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1663
- if tool_calls_block:
1664
- messages.append(tool_calls_block)
1665
- # Render todos stored with this message
1666
- if msg.get("todos"):
1667
- todos_block = format_todos_inline(msg["todos"], colors)
1668
- if todos_block:
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)
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))
1682
1790
 
1683
1791
  messages.append(format_loading(colors))
1684
1792
 
@@ -2805,6 +2913,70 @@ def toggle_view(view_value):
2805
2913
  )
2806
2914
 
2807
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
+
2808
2980
  # Canvas content update
2809
2981
  @app.callback(
2810
2982
  Output("canvas-content", "children"),
@@ -3284,6 +3456,206 @@ def add_display_inline_to_canvas(n_clicks_list, data_list, theme, collapsed_ids,
3284
3456
  return render_canvas_items(canvas_items, colors, collapsed_ids), "canvas"
3285
3457
 
3286
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
+
3287
3659
  # =============================================================================
3288
3660
  # THEME TOGGLE CALLBACK - Using DMC 2.4 forceColorScheme
3289
3661
  # =============================================================================