cowork-dash 0.2.0__py3-none-any.whl → 0.2.2__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,87 @@ 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
+ Output("chat-panel", "style")],
2923
+ [Input("collapse-sidebar-btn", "n_clicks"),
2924
+ Input("expand-sidebar-btn", "n_clicks")],
2925
+ [State("sidebar-collapsed", "data")],
2926
+ prevent_initial_call=True
2927
+ )
2928
+ def toggle_sidebar_collapse(collapse_clicks, expand_clicks, is_collapsed):
2929
+ """Toggle sidebar between collapsed and expanded states."""
2930
+ ctx = callback_context
2931
+ if not ctx.triggered:
2932
+ raise PreventUpdate
2933
+
2934
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2935
+
2936
+ # Determine new state based on which button was clicked
2937
+ if triggered_id == "collapse-sidebar-btn":
2938
+ new_collapsed = True
2939
+ elif triggered_id == "expand-sidebar-btn":
2940
+ new_collapsed = False
2941
+ else:
2942
+ raise PreventUpdate
2943
+
2944
+ if new_collapsed:
2945
+ # Collapsed state - hide sidebar, show expand button, expand chat panel
2946
+ return (
2947
+ {"display": "none"}, # Hide sidebar panel
2948
+ { # Show expand button - minimal width
2949
+ "display": "flex",
2950
+ "alignItems": "flex-start",
2951
+ "paddingTop": "8px",
2952
+ "paddingLeft": "2px",
2953
+ "paddingRight": "2px",
2954
+ "borderLeft": "1px solid var(--mantine-color-default-border)",
2955
+ "background": "var(--mantine-color-body)",
2956
+ },
2957
+ {"display": "none"}, # Hide resize handle
2958
+ True, # Store collapsed state
2959
+ { # Expand chat panel to fill space
2960
+ "flex": "1",
2961
+ "display": "flex",
2962
+ "flexDirection": "column",
2963
+ "background": "var(--mantine-color-body)",
2964
+ "minWidth": "0",
2965
+ },
2966
+ )
2967
+ else:
2968
+ # Expanded state - show sidebar, hide expand button, restore chat panel
2969
+ return (
2970
+ { # Show sidebar panel
2971
+ "flex": "1",
2972
+ "minWidth": "0",
2973
+ "minHeight": "0",
2974
+ "display": "flex",
2975
+ "flexDirection": "column",
2976
+ "background": "var(--mantine-color-body)",
2977
+ "borderLeft": "1px solid var(--mantine-color-default-border)",
2978
+ },
2979
+ {"display": "none"}, # Hide expand button
2980
+ { # Show resize handle
2981
+ "width": "3px",
2982
+ "cursor": "col-resize",
2983
+ "background": "transparent",
2984
+ "flexShrink": "0",
2985
+ },
2986
+ False, # Store expanded state
2987
+ { # Restore chat panel to original flex
2988
+ "flex": "3",
2989
+ "display": "flex",
2990
+ "flexDirection": "column",
2991
+ "background": "var(--mantine-color-body)",
2992
+ "minWidth": "0",
2993
+ },
2994
+ )
2995
+
2996
+
2808
2997
  # Canvas content update
2809
2998
  @app.callback(
2810
2999
  Output("canvas-content", "children"),
@@ -3284,6 +3473,206 @@ def add_display_inline_to_canvas(n_clicks_list, data_list, theme, collapsed_ids,
3284
3473
  return render_canvas_items(canvas_items, colors, collapsed_ids), "canvas"
3285
3474
 
3286
3475
 
3476
+ # =============================================================================
3477
+ # DOWNLOAD DISPLAY INLINE CALLBACK - Download display inline content
3478
+ # =============================================================================
3479
+
3480
+ @app.callback(
3481
+ Output("file-download", "data", allow_duplicate=True),
3482
+ Input({"type": "download-display-btn", "index": ALL}, "n_clicks"),
3483
+ State({"type": "display-inline-data", "index": ALL}, "data"),
3484
+ prevent_initial_call=True
3485
+ )
3486
+ def download_display_inline_content(n_clicks_list, data_list):
3487
+ """Download a display_inline item when the download button is clicked."""
3488
+ import base64
3489
+
3490
+ ctx = callback_context
3491
+ # Check if any button was actually clicked
3492
+ if not n_clicks_list or not any(n_clicks_list):
3493
+ raise PreventUpdate
3494
+
3495
+ if not ctx.triggered:
3496
+ raise PreventUpdate
3497
+
3498
+ triggered = ctx.triggered[0]
3499
+ triggered_id = triggered["prop_id"]
3500
+
3501
+ # Parse the pattern-matching ID to get the index
3502
+ try:
3503
+ # Format: {"type":"download-display-btn","index":"abc123"}.n_clicks
3504
+ id_part = triggered_id.rsplit(".", 1)[0]
3505
+ id_dict = json.loads(id_part)
3506
+ clicked_index = id_dict.get("index")
3507
+ except (json.JSONDecodeError, KeyError, AttributeError):
3508
+ raise PreventUpdate
3509
+
3510
+ if not clicked_index:
3511
+ raise PreventUpdate
3512
+
3513
+ # Find the corresponding data
3514
+ display_data = None
3515
+ for data in data_list:
3516
+ if data and data.get("_item_id") == clicked_index:
3517
+ display_data = data
3518
+ break
3519
+
3520
+ if not display_data:
3521
+ raise PreventUpdate
3522
+
3523
+ display_type = display_data.get("display_type", "text")
3524
+ data = display_data.get("data")
3525
+ title = display_data.get("title", "download")
3526
+ filename = display_data.get("filename")
3527
+
3528
+ # Generate appropriate filename and content based on type
3529
+ if display_type == "image":
3530
+ mime_type = display_data.get("mime_type", "image/png")
3531
+ ext = mime_type.split("/")[-1]
3532
+ if ext == "jpeg":
3533
+ ext = "jpg"
3534
+ download_filename = filename or f"{title}.{ext}"
3535
+ return dict(
3536
+ content=data,
3537
+ filename=download_filename,
3538
+ base64=True
3539
+ )
3540
+
3541
+ elif display_type == "pdf":
3542
+ download_filename = filename or f"{title}.pdf"
3543
+ return dict(
3544
+ content=data,
3545
+ filename=download_filename,
3546
+ base64=True
3547
+ )
3548
+
3549
+ elif display_type == "html":
3550
+ download_filename = filename or f"{title}.html"
3551
+ return dict(
3552
+ content=data,
3553
+ filename=download_filename
3554
+ )
3555
+
3556
+ elif display_type == "dataframe":
3557
+ # Download as CSV
3558
+ csv_data = display_data.get("csv", {})
3559
+ columns = csv_data.get("columns", [])
3560
+ rows = csv_data.get("data", [])
3561
+
3562
+ # Build CSV content
3563
+ import csv
3564
+ import io
3565
+ output = io.StringIO()
3566
+ writer = csv.writer(output)
3567
+ writer.writerow(columns)
3568
+ for row in rows:
3569
+ writer.writerow([row.get(col, "") for col in columns])
3570
+ csv_content = output.getvalue()
3571
+
3572
+ download_filename = filename or f"{title}.csv"
3573
+ return dict(
3574
+ content=csv_content,
3575
+ filename=download_filename
3576
+ )
3577
+
3578
+ elif display_type == "json":
3579
+ json_content = json.dumps(data, indent=2) if isinstance(data, (dict, list)) else str(data)
3580
+ download_filename = filename or f"{title}.json"
3581
+ return dict(
3582
+ content=json_content,
3583
+ filename=download_filename
3584
+ )
3585
+
3586
+ elif display_type == "plotly":
3587
+ # Download Plotly chart as JSON
3588
+ json_content = json.dumps(data, indent=2) if isinstance(data, dict) else str(data)
3589
+ download_filename = filename or f"{title}.json"
3590
+ return dict(
3591
+ content=json_content,
3592
+ filename=download_filename
3593
+ )
3594
+
3595
+ else:
3596
+ # Text or unknown - download as .txt
3597
+ text_content = str(data) if data else ""
3598
+ download_filename = filename or f"{title}.txt"
3599
+ return dict(
3600
+ content=text_content,
3601
+ filename=download_filename
3602
+ )
3603
+
3604
+
3605
+ # =============================================================================
3606
+ # FULLSCREEN PREVIEW CALLBACK - Open HTML/PDF in fullscreen modal
3607
+ # =============================================================================
3608
+
3609
+ @app.callback(
3610
+ [Output("fullscreen-preview-modal", "opened"),
3611
+ Output("fullscreen-preview-modal", "title"),
3612
+ Output("fullscreen-preview-content", "children")],
3613
+ Input({"type": "fullscreen-btn", "index": ALL}, "n_clicks"),
3614
+ State({"type": "fullscreen-data", "index": ALL}, "data"),
3615
+ prevent_initial_call=True
3616
+ )
3617
+ def open_fullscreen_preview(n_clicks_list, data_list):
3618
+ """Open fullscreen modal for HTML/PDF preview."""
3619
+ ctx = callback_context
3620
+ if not n_clicks_list or not any(n_clicks_list):
3621
+ raise PreventUpdate
3622
+
3623
+ # Find which button was clicked
3624
+ triggered = ctx.triggered[0]
3625
+ triggered_id = triggered["prop_id"]
3626
+
3627
+ try:
3628
+ id_part = triggered_id.rsplit(".", 1)[0]
3629
+ id_dict = json.loads(id_part)
3630
+ clicked_index = id_dict.get("index")
3631
+ except (json.JSONDecodeError, KeyError, AttributeError):
3632
+ raise PreventUpdate
3633
+
3634
+ # Find the corresponding data
3635
+ fullscreen_data = None
3636
+ for i, data in enumerate(data_list):
3637
+ if data and n_clicks_list[i]:
3638
+ # Match by checking trigger
3639
+ btn_ids = ctx.inputs_list[0]
3640
+ if i < len(btn_ids) and btn_ids[i].get("id", {}).get("index") == clicked_index:
3641
+ fullscreen_data = data
3642
+ break
3643
+
3644
+ if not fullscreen_data:
3645
+ raise PreventUpdate
3646
+
3647
+ content_type = fullscreen_data.get("type")
3648
+ content = fullscreen_data.get("content")
3649
+ title = fullscreen_data.get("title", "Preview")
3650
+
3651
+ if content_type == "html":
3652
+ preview_content = html.Iframe(
3653
+ srcDoc=content,
3654
+ style={
3655
+ "width": "100%",
3656
+ "height": "100%",
3657
+ "border": "none",
3658
+ "backgroundColor": "white",
3659
+ }
3660
+ )
3661
+ elif content_type == "pdf":
3662
+ preview_content = html.Iframe(
3663
+ src=content,
3664
+ style={
3665
+ "width": "100%",
3666
+ "height": "100%",
3667
+ "border": "none",
3668
+ }
3669
+ )
3670
+ else:
3671
+ preview_content = html.Div("Unsupported content type")
3672
+
3673
+ return True, title, preview_content
3674
+
3675
+
3287
3676
  # =============================================================================
3288
3677
  # THEME TOGGLE CALLBACK - Using DMC 2.4 forceColorScheme
3289
3678
  # =============================================================================