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