cowork-dash 0.1.8__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cowork_dash/agent.py +32 -11
- cowork_dash/app.py +888 -81
- cowork_dash/assets/app.js +34 -0
- cowork_dash/assets/styles.css +788 -697
- cowork_dash/canvas.py +8 -0
- cowork_dash/cli.py +9 -0
- cowork_dash/components.py +398 -55
- cowork_dash/config.py +12 -1
- cowork_dash/file_utils.py +65 -9
- cowork_dash/layout.py +2 -2
- cowork_dash/sandbox.py +361 -0
- cowork_dash/tools.py +734 -79
- {cowork_dash-0.1.8.dist-info → cowork_dash-0.2.0.dist-info}/METADATA +1 -1
- cowork_dash-0.2.0.dist-info/RECORD +23 -0
- cowork_dash-0.1.8.dist-info/RECORD +0 -22
- {cowork_dash-0.1.8.dist-info → cowork_dash-0.2.0.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.8.dist-info → cowork_dash-0.2.0.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.8.dist-info → cowork_dash-0.2.0.dist-info}/licenses/LICENSE +0 -0
cowork_dash/app.py
CHANGED
|
@@ -17,7 +17,15 @@ from typing import Optional, Dict, Any, List
|
|
|
17
17
|
from dotenv import load_dotenv
|
|
18
18
|
load_dotenv()
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# Early pandas import to prevent circular import issues with Plotly's JSON serializer.
|
|
21
|
+
# Plotly lazily imports pandas and checks `obj is pd.NaT` which fails if pandas
|
|
22
|
+
# is partially initialized due to concurrent imports.
|
|
23
|
+
try:
|
|
24
|
+
import pandas
|
|
25
|
+
except (ImportError, AttributeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
from dash import Dash, html, dcc, Input, Output, State, callback_context, no_update, ALL
|
|
21
29
|
from dash.exceptions import PreventUpdate
|
|
22
30
|
import dash_mantine_components as dmc
|
|
23
31
|
from dash_iconify import DashIconify
|
|
@@ -27,7 +35,7 @@ from .canvas import export_canvas_to_markdown, load_canvas_from_markdown
|
|
|
27
35
|
from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
|
|
28
36
|
from .components import (
|
|
29
37
|
format_message, format_loading, format_thinking, format_todos_inline, render_canvas_items, format_tool_calls_inline,
|
|
30
|
-
format_interrupt
|
|
38
|
+
format_interrupt, extract_display_inline_results, render_display_inline_result, extract_thinking_from_tool_calls
|
|
31
39
|
)
|
|
32
40
|
from .layout import create_layout as create_layout_component
|
|
33
41
|
from .virtual_fs import get_session_manager
|
|
@@ -243,6 +251,7 @@ _agent_state = {
|
|
|
243
251
|
"thinking": "",
|
|
244
252
|
"todos": [],
|
|
245
253
|
"tool_calls": [], # Current turn's tool calls (reset each turn)
|
|
254
|
+
"display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
|
|
246
255
|
"canvas": load_canvas_from_markdown(WORKSPACE_ROOT) if not USE_VIRTUAL_FS else [], # Load from canvas.md if exists (physical FS only)
|
|
247
256
|
"response": "",
|
|
248
257
|
"error": None,
|
|
@@ -250,6 +259,7 @@ _agent_state = {
|
|
|
250
259
|
"last_update": time.time(),
|
|
251
260
|
"start_time": None, # Track when agent started for response time calculation
|
|
252
261
|
"stop_requested": False, # Flag to request agent stop
|
|
262
|
+
"stop_event": None, # Threading event for immediate stop signaling
|
|
253
263
|
}
|
|
254
264
|
_agent_state_lock = threading.Lock()
|
|
255
265
|
|
|
@@ -267,11 +277,13 @@ def _get_default_agent_state() -> Dict[str, Any]:
|
|
|
267
277
|
"thinking": "",
|
|
268
278
|
"todos": [],
|
|
269
279
|
"tool_calls": [],
|
|
280
|
+
"display_inline_items": [], # Items pushed by display_inline tool (bypasses LangGraph)
|
|
270
281
|
"canvas": [],
|
|
271
282
|
"response": "",
|
|
272
283
|
"error": None,
|
|
273
284
|
"interrupt": None,
|
|
274
285
|
"last_update": time.time(),
|
|
286
|
+
"stop_event": None, # Threading event for immediate stop signaling
|
|
275
287
|
"start_time": None,
|
|
276
288
|
"stop_requested": False,
|
|
277
289
|
}
|
|
@@ -315,7 +327,9 @@ def _get_session_state_lock() -> threading.Lock:
|
|
|
315
327
|
|
|
316
328
|
|
|
317
329
|
def request_agent_stop(session_id: Optional[str] = None):
|
|
318
|
-
"""Request the agent to stop execution.
|
|
330
|
+
"""Request the agent to stop execution immediately.
|
|
331
|
+
|
|
332
|
+
Sets the stop_requested flag and signals the stop_event for immediate interruption.
|
|
319
333
|
|
|
320
334
|
Args:
|
|
321
335
|
session_id: Session ID for virtual FS mode, None for physical FS mode.
|
|
@@ -325,10 +339,16 @@ def request_agent_stop(session_id: Optional[str] = None):
|
|
|
325
339
|
with _session_agents_lock:
|
|
326
340
|
state["stop_requested"] = True
|
|
327
341
|
state["last_update"] = time.time()
|
|
342
|
+
# Signal the stop event for immediate interruption
|
|
343
|
+
if state.get("stop_event"):
|
|
344
|
+
state["stop_event"].set()
|
|
328
345
|
else:
|
|
329
346
|
with _agent_state_lock:
|
|
330
347
|
_agent_state["stop_requested"] = True
|
|
331
348
|
_agent_state["last_update"] = time.time()
|
|
349
|
+
# Signal the stop event for immediate interruption
|
|
350
|
+
if _agent_state.get("stop_event"):
|
|
351
|
+
_agent_state["stop_event"].set()
|
|
332
352
|
|
|
333
353
|
|
|
334
354
|
def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None, session_id: Optional[str] = None):
|
|
@@ -359,6 +379,12 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
359
379
|
current_state["running"] = False
|
|
360
380
|
return
|
|
361
381
|
|
|
382
|
+
# Create a stop event for immediate interruption
|
|
383
|
+
stop_event = threading.Event()
|
|
384
|
+
with state_lock:
|
|
385
|
+
current_state["stop_event"] = stop_event
|
|
386
|
+
current_state["stop_requested"] = False # Reset stop flag
|
|
387
|
+
|
|
362
388
|
# Track tool calls by their ID for updating status
|
|
363
389
|
tool_call_map = {}
|
|
364
390
|
|
|
@@ -405,6 +431,17 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
405
431
|
# Resume from interrupt
|
|
406
432
|
from langgraph.types import Command
|
|
407
433
|
agent_input = Command(resume=resume_data)
|
|
434
|
+
|
|
435
|
+
# Rebuild tool_call_map from existing tool calls and mark pending ones as running
|
|
436
|
+
with state_lock:
|
|
437
|
+
for tc in current_state.get("tool_calls", []):
|
|
438
|
+
tc_id = tc.get("id")
|
|
439
|
+
if tc_id:
|
|
440
|
+
tool_call_map[tc_id] = tc
|
|
441
|
+
# Mark pending tool calls back to running since we're resuming
|
|
442
|
+
if tc.get("status") == "pending":
|
|
443
|
+
tc["status"] = "running"
|
|
444
|
+
current_state["last_update"] = time.time()
|
|
408
445
|
else:
|
|
409
446
|
# Inject workspace context into the message if available
|
|
410
447
|
if workspace_path:
|
|
@@ -415,14 +452,15 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
415
452
|
agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
|
|
416
453
|
|
|
417
454
|
for update in current_agent.stream(agent_input, stream_mode="updates", config=stream_config):
|
|
418
|
-
# Check if stop was requested
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
current_state["response"] = current_state.get("response", "") + "\n\
|
|
455
|
+
# Check if stop was requested (via flag or event)
|
|
456
|
+
if stop_event.is_set() or current_state.get("stop_requested"):
|
|
457
|
+
with state_lock:
|
|
458
|
+
current_state["response"] = current_state.get("response", "") + "\n\n⏹️ Agent stopped by user."
|
|
422
459
|
current_state["running"] = False
|
|
423
460
|
current_state["stop_requested"] = False
|
|
461
|
+
current_state["stop_event"] = None
|
|
424
462
|
current_state["last_update"] = time.time()
|
|
425
|
-
|
|
463
|
+
return
|
|
426
464
|
|
|
427
465
|
# Check for interrupt
|
|
428
466
|
if isinstance(update, dict) and "__interrupt__" in update:
|
|
@@ -431,6 +469,10 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
431
469
|
with state_lock:
|
|
432
470
|
current_state["interrupt"] = interrupt_data
|
|
433
471
|
current_state["running"] = False # Pause until user responds
|
|
472
|
+
# Mark any "running" tool calls as "pending" since we're waiting for user approval
|
|
473
|
+
for tc in current_state["tool_calls"]:
|
|
474
|
+
if tc.get("status") == "running":
|
|
475
|
+
tc["status"] = "pending"
|
|
434
476
|
current_state["last_update"] = time.time()
|
|
435
477
|
return # Exit stream, wait for user to resume
|
|
436
478
|
|
|
@@ -444,14 +486,26 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
444
486
|
|
|
445
487
|
# Capture AIMessage tool_calls
|
|
446
488
|
if msg_type == 'AIMessage' and hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
|
|
447
|
-
new_tool_calls = []
|
|
448
|
-
for tc in last_msg.tool_calls:
|
|
449
|
-
serialized = _serialize_tool_call(tc)
|
|
450
|
-
tool_call_map[serialized["id"]] = serialized
|
|
451
|
-
new_tool_calls.append(serialized)
|
|
452
|
-
|
|
453
489
|
with state_lock:
|
|
454
|
-
|
|
490
|
+
# Get existing tool call IDs to avoid duplicates
|
|
491
|
+
existing_ids = {tc.get("id") for tc in current_state["tool_calls"]}
|
|
492
|
+
|
|
493
|
+
for tc in last_msg.tool_calls:
|
|
494
|
+
serialized = _serialize_tool_call(tc)
|
|
495
|
+
tc_id = serialized["id"]
|
|
496
|
+
|
|
497
|
+
# Only add if not already in the list (avoid duplicates on resume)
|
|
498
|
+
if tc_id not in existing_ids:
|
|
499
|
+
tool_call_map[tc_id] = serialized
|
|
500
|
+
current_state["tool_calls"].append(serialized)
|
|
501
|
+
existing_ids.add(tc_id)
|
|
502
|
+
else:
|
|
503
|
+
# Update the map to reference the existing tool call
|
|
504
|
+
for existing_tc in current_state["tool_calls"]:
|
|
505
|
+
if existing_tc.get("id") == tc_id:
|
|
506
|
+
tool_call_map[tc_id] = existing_tc
|
|
507
|
+
break
|
|
508
|
+
|
|
455
509
|
current_state["last_update"] = time.time()
|
|
456
510
|
|
|
457
511
|
elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
|
|
@@ -480,10 +534,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
480
534
|
content_lower.startswith("traceback")):
|
|
481
535
|
status = "error"
|
|
482
536
|
|
|
483
|
-
#
|
|
484
|
-
|
|
485
|
-
if
|
|
486
|
-
|
|
537
|
+
# display_inline now pushes rich content directly to queue
|
|
538
|
+
# and returns a simple confirmation message, so no special handling needed
|
|
539
|
+
if isinstance(content, str):
|
|
540
|
+
# Truncate result for display
|
|
541
|
+
result_display = content[:1000] + "..." if len(content) > 1000 else content
|
|
542
|
+
else:
|
|
543
|
+
# Convert other types to string and truncate
|
|
544
|
+
result_display = str(content)
|
|
545
|
+
if len(result_display) > 1000:
|
|
546
|
+
result_display = result_display[:1000] + "..."
|
|
487
547
|
|
|
488
548
|
_update_tool_call_result(tool_call_id, result_display, status)
|
|
489
549
|
|
|
@@ -688,6 +748,7 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
688
748
|
|
|
689
749
|
with state_lock:
|
|
690
750
|
current_state["running"] = False
|
|
751
|
+
current_state["stop_event"] = None # Clean up stop event
|
|
691
752
|
current_state["last_update"] = time.time()
|
|
692
753
|
|
|
693
754
|
|
|
@@ -891,6 +952,7 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
|
|
|
891
952
|
|
|
892
953
|
with state_lock:
|
|
893
954
|
current_state["running"] = False
|
|
955
|
+
current_state["stop_event"] = None # Clean up stop event
|
|
894
956
|
current_state["response"] = f"Action rejected{tool_info}: {reject_message}"
|
|
895
957
|
current_state["last_update"] = time.time()
|
|
896
958
|
|
|
@@ -947,9 +1009,34 @@ def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
|
|
|
947
1009
|
state["tool_calls"] = copy.deepcopy(current_state["tool_calls"])
|
|
948
1010
|
state["todos"] = copy.deepcopy(current_state["todos"])
|
|
949
1011
|
state["canvas"] = copy.deepcopy(current_state["canvas"])
|
|
1012
|
+
state["display_inline_items"] = copy.deepcopy(current_state.get("display_inline_items", []))
|
|
950
1013
|
return state
|
|
951
1014
|
|
|
952
1015
|
|
|
1016
|
+
def push_display_inline_item(item: Dict[str, Any], session_id: Optional[str] = None):
|
|
1017
|
+
"""Push a display_inline item to the agent state (thread-safe).
|
|
1018
|
+
|
|
1019
|
+
This is called by the display_inline tool to store rich content
|
|
1020
|
+
that bypasses LangGraph serialization.
|
|
1021
|
+
|
|
1022
|
+
Args:
|
|
1023
|
+
item: The display result dict with type, display_type, data, etc.
|
|
1024
|
+
session_id: Session ID for virtual FS mode, None for physical FS mode.
|
|
1025
|
+
"""
|
|
1026
|
+
if USE_VIRTUAL_FS and session_id:
|
|
1027
|
+
current_state = _get_session_state(session_id)
|
|
1028
|
+
state_lock = _session_agents_lock
|
|
1029
|
+
else:
|
|
1030
|
+
current_state = _agent_state
|
|
1031
|
+
state_lock = _agent_state_lock
|
|
1032
|
+
|
|
1033
|
+
with state_lock:
|
|
1034
|
+
if "display_inline_items" not in current_state:
|
|
1035
|
+
current_state["display_inline_items"] = []
|
|
1036
|
+
current_state["display_inline_items"].append(item)
|
|
1037
|
+
current_state["last_update"] = time.time()
|
|
1038
|
+
|
|
1039
|
+
|
|
953
1040
|
def reset_agent_state(session_id: Optional[str] = None):
|
|
954
1041
|
"""Reset agent state for a fresh session (thread-safe).
|
|
955
1042
|
|
|
@@ -971,8 +1058,11 @@ def reset_agent_state(session_id: Optional[str] = None):
|
|
|
971
1058
|
current_state["thinking"] = ""
|
|
972
1059
|
current_state["todos"] = []
|
|
973
1060
|
current_state["tool_calls"] = []
|
|
1061
|
+
current_state["display_inline_items"] = []
|
|
974
1062
|
current_state["response"] = ""
|
|
975
1063
|
current_state["error"] = None
|
|
1064
|
+
current_state["stop_event"] = None
|
|
1065
|
+
current_state["stop_requested"] = False
|
|
976
1066
|
current_state["interrupt"] = None
|
|
977
1067
|
current_state["start_time"] = None
|
|
978
1068
|
current_state["stop_requested"] = False
|
|
@@ -1087,8 +1177,10 @@ def display_initial_messages(history, theme, skip_render, session_initialized, s
|
|
|
1087
1177
|
for msg in history:
|
|
1088
1178
|
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
1089
1179
|
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
|
|
1180
|
+
# Order: tool calls -> todos -> thinking -> display inline items
|
|
1090
1181
|
# Render tool calls stored with this message
|
|
1091
1182
|
if msg.get("tool_calls"):
|
|
1183
|
+
# Show collapsed tool calls section first
|
|
1092
1184
|
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
1093
1185
|
if tool_calls_block:
|
|
1094
1186
|
messages.append(tool_calls_block)
|
|
@@ -1097,6 +1189,18 @@ def display_initial_messages(history, theme, skip_render, session_initialized, s
|
|
|
1097
1189
|
todos_block = format_todos_inline(msg["todos"], colors)
|
|
1098
1190
|
if todos_block:
|
|
1099
1191
|
messages.append(todos_block)
|
|
1192
|
+
# Extract and show thinking from tool calls
|
|
1193
|
+
if msg.get("tool_calls"):
|
|
1194
|
+
thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
|
|
1195
|
+
messages.extend(thinking_blocks)
|
|
1196
|
+
# Extract and show display_inline results prominently
|
|
1197
|
+
inline_results = extract_display_inline_results(msg["tool_calls"], colors)
|
|
1198
|
+
messages.extend(inline_results)
|
|
1199
|
+
# Render display_inline items stored with this message
|
|
1200
|
+
if msg.get("display_inline_items"):
|
|
1201
|
+
for item in msg["display_inline_items"]:
|
|
1202
|
+
rendered = render_display_inline_result(item, colors)
|
|
1203
|
+
messages.append(rendered)
|
|
1100
1204
|
return messages, False, True, new_session_id
|
|
1101
1205
|
|
|
1102
1206
|
|
|
@@ -1130,7 +1234,7 @@ def initialize_file_tree_for_session(session_initialized, session_id, current_wo
|
|
|
1130
1234
|
current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
|
|
1131
1235
|
|
|
1132
1236
|
# Build and render file tree
|
|
1133
|
-
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
1237
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, workspace_root=workspace_root)
|
|
1134
1238
|
|
|
1135
1239
|
|
|
1136
1240
|
# Chat callbacks
|
|
@@ -1165,8 +1269,9 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
|
|
|
1165
1269
|
is_new = (i == len(history) - 1)
|
|
1166
1270
|
msg_response_time = m.get("response_time") if m["role"] == "assistant" else None
|
|
1167
1271
|
messages.append(format_message(m["role"], m["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
|
|
1168
|
-
#
|
|
1272
|
+
# Order: tool calls -> todos -> thinking -> display inline items
|
|
1169
1273
|
if m.get("tool_calls"):
|
|
1274
|
+
# Show collapsed tool calls section first
|
|
1170
1275
|
tool_calls_block = format_tool_calls_inline(m["tool_calls"], colors)
|
|
1171
1276
|
if tool_calls_block:
|
|
1172
1277
|
messages.append(tool_calls_block)
|
|
@@ -1175,6 +1280,13 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
|
|
|
1175
1280
|
todos_block = format_todos_inline(m["todos"], colors)
|
|
1176
1281
|
if todos_block:
|
|
1177
1282
|
messages.append(todos_block)
|
|
1283
|
+
# Extract and show thinking from tool calls
|
|
1284
|
+
if m.get("tool_calls"):
|
|
1285
|
+
thinking_blocks = extract_thinking_from_tool_calls(m["tool_calls"], colors)
|
|
1286
|
+
messages.extend(thinking_blocks)
|
|
1287
|
+
# Extract and show display_inline results prominently
|
|
1288
|
+
inline_results = extract_display_inline_results(m["tool_calls"], colors)
|
|
1289
|
+
messages.extend(inline_results)
|
|
1178
1290
|
|
|
1179
1291
|
messages.append(format_loading(colors))
|
|
1180
1292
|
|
|
@@ -1220,14 +1332,18 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
|
|
|
1220
1332
|
history = history or []
|
|
1221
1333
|
colors = get_colors(theme or "light")
|
|
1222
1334
|
|
|
1335
|
+
# Get display_inline items from agent state (bypasses LangGraph serialization)
|
|
1336
|
+
display_inline_items = state.get("display_inline_items", [])
|
|
1337
|
+
|
|
1223
1338
|
def render_history_messages(history_items):
|
|
1224
|
-
"""Render all history items including tool calls and todos."""
|
|
1339
|
+
"""Render all history items including tool calls, display_inline items, and todos."""
|
|
1225
1340
|
messages = []
|
|
1226
1341
|
for msg in history_items:
|
|
1227
1342
|
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
1228
1343
|
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
|
|
1229
|
-
#
|
|
1344
|
+
# Order: tool calls -> todos -> thinking -> display inline items
|
|
1230
1345
|
if msg.get("tool_calls"):
|
|
1346
|
+
# Show collapsed tool calls section first
|
|
1231
1347
|
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
1232
1348
|
if tool_calls_block:
|
|
1233
1349
|
messages.append(tool_calls_block)
|
|
@@ -1236,6 +1352,18 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
|
|
|
1236
1352
|
todos_block = format_todos_inline(msg["todos"], colors)
|
|
1237
1353
|
if todos_block:
|
|
1238
1354
|
messages.append(todos_block)
|
|
1355
|
+
# Extract and show thinking from tool calls
|
|
1356
|
+
if msg.get("tool_calls"):
|
|
1357
|
+
thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
|
|
1358
|
+
messages.extend(thinking_blocks)
|
|
1359
|
+
# Extract and show display_inline results prominently
|
|
1360
|
+
inline_results = extract_display_inline_results(msg["tool_calls"], colors)
|
|
1361
|
+
messages.extend(inline_results)
|
|
1362
|
+
# Render display_inline items stored with this message
|
|
1363
|
+
if msg.get("display_inline_items"):
|
|
1364
|
+
for item in msg["display_inline_items"]:
|
|
1365
|
+
rendered = render_display_inline_result(item, colors)
|
|
1366
|
+
messages.append(rendered)
|
|
1239
1367
|
return messages
|
|
1240
1368
|
|
|
1241
1369
|
# Check for interrupt (human-in-the-loop)
|
|
@@ -1243,13 +1371,9 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
|
|
|
1243
1371
|
# Agent is paused waiting for user input
|
|
1244
1372
|
messages = render_history_messages(history)
|
|
1245
1373
|
|
|
1246
|
-
#
|
|
1247
|
-
if state["thinking"]:
|
|
1248
|
-
thinking_block = format_thinking(state["thinking"], colors)
|
|
1249
|
-
if thinking_block:
|
|
1250
|
-
messages.append(thinking_block)
|
|
1251
|
-
|
|
1374
|
+
# Order: tool calls -> todos -> thinking -> display inline items
|
|
1252
1375
|
if state.get("tool_calls"):
|
|
1376
|
+
# Show collapsed tool calls section first
|
|
1253
1377
|
tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
|
|
1254
1378
|
if tool_calls_block:
|
|
1255
1379
|
messages.append(tool_calls_block)
|
|
@@ -1259,6 +1383,19 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
|
|
|
1259
1383
|
if todos_block:
|
|
1260
1384
|
messages.append(todos_block)
|
|
1261
1385
|
|
|
1386
|
+
if state.get("tool_calls"):
|
|
1387
|
+
# Extract and show thinking from tool calls
|
|
1388
|
+
thinking_blocks = extract_thinking_from_tool_calls(state["tool_calls"], colors)
|
|
1389
|
+
messages.extend(thinking_blocks)
|
|
1390
|
+
# Extract and show display_inline results prominently
|
|
1391
|
+
inline_results = extract_display_inline_results(state["tool_calls"], colors)
|
|
1392
|
+
messages.extend(inline_results)
|
|
1393
|
+
|
|
1394
|
+
# Render any queued display_inline items (bypasses LangGraph serialization)
|
|
1395
|
+
for item in display_inline_items:
|
|
1396
|
+
rendered = render_display_inline_result(item, colors)
|
|
1397
|
+
messages.append(rendered)
|
|
1398
|
+
|
|
1262
1399
|
# Add interrupt UI
|
|
1263
1400
|
interrupt_block = format_interrupt(state["interrupt"], colors)
|
|
1264
1401
|
if interrupt_block:
|
|
@@ -1274,15 +1411,20 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
|
|
|
1274
1411
|
if state.get("start_time"):
|
|
1275
1412
|
response_time = time.time() - state["start_time"]
|
|
1276
1413
|
|
|
1277
|
-
# Agent finished - store tool calls and
|
|
1414
|
+
# Agent finished - store tool calls, todos, and display_inline items with the USER message
|
|
1415
|
+
# (they appear after user msg in the UI)
|
|
1416
|
+
saved_display_inline_items = False
|
|
1278
1417
|
if history:
|
|
1279
|
-
# Find the last user message and attach tool calls and
|
|
1418
|
+
# Find the last user message and attach tool calls, todos, and display_inline items to it
|
|
1280
1419
|
for i in range(len(history) - 1, -1, -1):
|
|
1281
1420
|
if history[i]["role"] == "user":
|
|
1282
1421
|
if state.get("tool_calls"):
|
|
1283
1422
|
history[i]["tool_calls"] = state["tool_calls"]
|
|
1284
1423
|
if state.get("todos"):
|
|
1285
1424
|
history[i]["todos"] = state["todos"]
|
|
1425
|
+
if display_inline_items:
|
|
1426
|
+
history[i]["display_inline_items"] = display_inline_items
|
|
1427
|
+
saved_display_inline_items = True
|
|
1286
1428
|
break
|
|
1287
1429
|
|
|
1288
1430
|
# Add assistant response to history (with response time)
|
|
@@ -1295,12 +1437,13 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
|
|
|
1295
1437
|
history.append(assistant_msg)
|
|
1296
1438
|
|
|
1297
1439
|
# Render all history (tool calls and todos are now part of history)
|
|
1440
|
+
# Order: tool calls -> todos -> thinking -> display inline items
|
|
1298
1441
|
final_messages = []
|
|
1299
1442
|
for i, msg in enumerate(history):
|
|
1300
1443
|
is_new = (i >= len(history) - 1)
|
|
1301
1444
|
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
1302
1445
|
final_messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
|
|
1303
|
-
#
|
|
1446
|
+
# Show collapsed tool calls section first
|
|
1304
1447
|
if msg.get("tool_calls"):
|
|
1305
1448
|
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
1306
1449
|
if tool_calls_block:
|
|
@@ -1310,21 +1453,35 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
|
|
|
1310
1453
|
todos_block = format_todos_inline(msg["todos"], colors)
|
|
1311
1454
|
if todos_block:
|
|
1312
1455
|
final_messages.append(todos_block)
|
|
1456
|
+
# Extract and show thinking from tool calls
|
|
1457
|
+
if msg.get("tool_calls"):
|
|
1458
|
+
thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
|
|
1459
|
+
final_messages.extend(thinking_blocks)
|
|
1460
|
+
# Extract and show display_inline results prominently
|
|
1461
|
+
inline_results = extract_display_inline_results(msg["tool_calls"], colors)
|
|
1462
|
+
final_messages.extend(inline_results)
|
|
1463
|
+
# Render display_inline items stored with this message
|
|
1464
|
+
if msg.get("display_inline_items"):
|
|
1465
|
+
for item in msg["display_inline_items"]:
|
|
1466
|
+
rendered = render_display_inline_result(item, colors)
|
|
1467
|
+
final_messages.append(rendered)
|
|
1468
|
+
|
|
1469
|
+
# Render any NEW queued display_inline items only if not already saved to history
|
|
1470
|
+
# (avoids duplicate rendering)
|
|
1471
|
+
if not saved_display_inline_items:
|
|
1472
|
+
for item in display_inline_items:
|
|
1473
|
+
rendered = render_display_inline_result(item, colors)
|
|
1474
|
+
final_messages.append(rendered)
|
|
1313
1475
|
|
|
1314
1476
|
# Disable polling, set skip flag to prevent display_initial_messages from re-rendering
|
|
1315
1477
|
return final_messages, history, True, True
|
|
1316
1478
|
else:
|
|
1317
|
-
# Agent still running - show loading with current
|
|
1479
|
+
# Agent still running - show loading with current tool_calls/todos/thinking
|
|
1318
1480
|
messages = render_history_messages(history)
|
|
1319
1481
|
|
|
1320
|
-
#
|
|
1321
|
-
if state["thinking"]:
|
|
1322
|
-
thinking_block = format_thinking(state["thinking"], colors)
|
|
1323
|
-
if thinking_block:
|
|
1324
|
-
messages.append(thinking_block)
|
|
1325
|
-
|
|
1326
|
-
# Add current tool calls if available
|
|
1482
|
+
# Order: tool calls -> todos -> thinking -> display inline items
|
|
1327
1483
|
if state.get("tool_calls"):
|
|
1484
|
+
# Show collapsed tool calls section first
|
|
1328
1485
|
tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
|
|
1329
1486
|
if tool_calls_block:
|
|
1330
1487
|
messages.append(tool_calls_block)
|
|
@@ -1335,6 +1492,19 @@ def poll_agent_updates(n_intervals, history, pending_message, theme, session_id)
|
|
|
1335
1492
|
if todos_block:
|
|
1336
1493
|
messages.append(todos_block)
|
|
1337
1494
|
|
|
1495
|
+
if state.get("tool_calls"):
|
|
1496
|
+
# Extract and show thinking from tool calls
|
|
1497
|
+
thinking_blocks = extract_thinking_from_tool_calls(state["tool_calls"], colors)
|
|
1498
|
+
messages.extend(thinking_blocks)
|
|
1499
|
+
# Extract and show display_inline results prominently
|
|
1500
|
+
inline_results = extract_display_inline_results(state["tool_calls"], colors)
|
|
1501
|
+
messages.extend(inline_results)
|
|
1502
|
+
|
|
1503
|
+
# Render any queued display_inline items (bypasses LangGraph serialization)
|
|
1504
|
+
for item in display_inline_items:
|
|
1505
|
+
rendered = render_display_inline_result(item, colors)
|
|
1506
|
+
messages.append(rendered)
|
|
1507
|
+
|
|
1338
1508
|
# Add loading indicator
|
|
1339
1509
|
messages.append(format_loading(colors))
|
|
1340
1510
|
|
|
@@ -1380,12 +1550,14 @@ def handle_stop_button(n_clicks, history, theme, session_id):
|
|
|
1380
1550
|
request_agent_stop(session_id)
|
|
1381
1551
|
|
|
1382
1552
|
# Render current messages with a stopping indicator
|
|
1553
|
+
# Order: tool calls -> todos -> thinking -> display inline items
|
|
1383
1554
|
def render_history_messages(history):
|
|
1384
1555
|
messages = []
|
|
1385
1556
|
for i, msg in enumerate(history):
|
|
1386
1557
|
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
1387
1558
|
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
|
|
1388
1559
|
if msg.get("tool_calls"):
|
|
1560
|
+
# Show collapsed tool calls section first
|
|
1389
1561
|
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
1390
1562
|
if tool_calls_block:
|
|
1391
1563
|
messages.append(tool_calls_block)
|
|
@@ -1393,6 +1565,18 @@ def handle_stop_button(n_clicks, history, theme, session_id):
|
|
|
1393
1565
|
todos_block = format_todos_inline(msg["todos"], colors)
|
|
1394
1566
|
if todos_block:
|
|
1395
1567
|
messages.append(todos_block)
|
|
1568
|
+
if msg.get("tool_calls"):
|
|
1569
|
+
# Extract and show thinking from tool calls
|
|
1570
|
+
thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
|
|
1571
|
+
messages.extend(thinking_blocks)
|
|
1572
|
+
# Extract and show display_inline results prominently
|
|
1573
|
+
inline_results = extract_display_inline_results(msg["tool_calls"], colors)
|
|
1574
|
+
messages.extend(inline_results)
|
|
1575
|
+
# Render display_inline items stored with this message
|
|
1576
|
+
if msg.get("display_inline_items"):
|
|
1577
|
+
for item in msg["display_inline_items"]:
|
|
1578
|
+
rendered = render_display_inline_result(item, colors)
|
|
1579
|
+
messages.append(rendered)
|
|
1396
1580
|
return messages
|
|
1397
1581
|
|
|
1398
1582
|
messages = render_history_messages(history)
|
|
@@ -1468,12 +1652,13 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1468
1652
|
resume_agent_from_interrupt(decision, action, session_id=session_id)
|
|
1469
1653
|
|
|
1470
1654
|
# Show loading state while agent resumes
|
|
1655
|
+
# Order: tool calls -> todos -> thinking -> display inline items
|
|
1471
1656
|
messages = []
|
|
1472
1657
|
for msg in history:
|
|
1473
1658
|
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
1474
1659
|
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
|
|
1475
|
-
# Render tool calls stored with this message
|
|
1476
1660
|
if msg.get("tool_calls"):
|
|
1661
|
+
# Show collapsed tool calls section first
|
|
1477
1662
|
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
1478
1663
|
if tool_calls_block:
|
|
1479
1664
|
messages.append(tool_calls_block)
|
|
@@ -1482,6 +1667,18 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1482
1667
|
todos_block = format_todos_inline(msg["todos"], colors)
|
|
1483
1668
|
if todos_block:
|
|
1484
1669
|
messages.append(todos_block)
|
|
1670
|
+
if msg.get("tool_calls"):
|
|
1671
|
+
# Extract and show thinking from tool calls
|
|
1672
|
+
thinking_blocks = extract_thinking_from_tool_calls(msg["tool_calls"], colors)
|
|
1673
|
+
messages.extend(thinking_blocks)
|
|
1674
|
+
# Extract and show display_inline results prominently
|
|
1675
|
+
inline_results = extract_display_inline_results(msg["tool_calls"], colors)
|
|
1676
|
+
messages.extend(inline_results)
|
|
1677
|
+
# Render display_inline items stored with this message
|
|
1678
|
+
if msg.get("display_inline_items"):
|
|
1679
|
+
for item in msg["display_inline_items"]:
|
|
1680
|
+
rendered = render_display_inline_result(item, colors)
|
|
1681
|
+
messages.append(rendered)
|
|
1485
1682
|
|
|
1486
1683
|
messages.append(format_loading(colors))
|
|
1487
1684
|
|
|
@@ -1493,7 +1690,8 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1493
1690
|
@app.callback(
|
|
1494
1691
|
[Output({"type": "folder-children", "path": ALL}, "style"),
|
|
1495
1692
|
Output({"type": "folder-icon", "path": ALL}, "style"),
|
|
1496
|
-
Output({"type": "folder-children", "path": ALL}, "children")
|
|
1693
|
+
Output({"type": "folder-children", "path": ALL}, "children"),
|
|
1694
|
+
Output("expanded-folders", "data")],
|
|
1497
1695
|
Input({"type": "folder-icon", "path": ALL}, "n_clicks"),
|
|
1498
1696
|
[State({"type": "folder-header", "path": ALL}, "id"),
|
|
1499
1697
|
State({"type": "folder-header", "path": ALL}, "data-realpath"),
|
|
@@ -1503,16 +1701,18 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1503
1701
|
State({"type": "folder-icon", "path": ALL}, "style"),
|
|
1504
1702
|
State({"type": "folder-children", "path": ALL}, "children"),
|
|
1505
1703
|
State("theme-store", "data"),
|
|
1506
|
-
State("session-id", "data")
|
|
1704
|
+
State("session-id", "data"),
|
|
1705
|
+
State("expanded-folders", "data")],
|
|
1507
1706
|
prevent_initial_call=True
|
|
1508
1707
|
)
|
|
1509
|
-
def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme, session_id):
|
|
1708
|
+
def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme, session_id, expanded_folders):
|
|
1510
1709
|
"""Toggle folder expansion and lazy load contents if needed."""
|
|
1511
1710
|
ctx = callback_context
|
|
1512
1711
|
if not ctx.triggered or not any(n_clicks):
|
|
1513
1712
|
raise PreventUpdate
|
|
1514
1713
|
|
|
1515
1714
|
colors = get_colors(theme or "light")
|
|
1715
|
+
expanded_folders = expanded_folders or []
|
|
1516
1716
|
|
|
1517
1717
|
# Get workspace for this session (virtual or physical)
|
|
1518
1718
|
workspace_root = get_workspace_for_session(session_id)
|
|
@@ -1538,6 +1738,9 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
|
|
|
1538
1738
|
new_icon_styles = []
|
|
1539
1739
|
new_children_content = []
|
|
1540
1740
|
|
|
1741
|
+
# Track whether we're expanding or collapsing the clicked folder
|
|
1742
|
+
will_expand = None
|
|
1743
|
+
|
|
1541
1744
|
# Process all folder-children elements
|
|
1542
1745
|
for i, child_id in enumerate(children_ids):
|
|
1543
1746
|
path = child_id["path"]
|
|
@@ -1547,6 +1750,7 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
|
|
|
1547
1750
|
if path == clicked_path:
|
|
1548
1751
|
# Toggle this folder
|
|
1549
1752
|
is_expanded = current_style.get("display") != "none"
|
|
1753
|
+
will_expand = not is_expanded
|
|
1550
1754
|
new_children_styles.append({"display": "none" if is_expanded else "block"})
|
|
1551
1755
|
|
|
1552
1756
|
# If expanding and content is just "Loading...", load the actual contents
|
|
@@ -1560,7 +1764,9 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
|
|
|
1560
1764
|
folder_items = load_folder_contents(folder_rel_path, workspace_root)
|
|
1561
1765
|
loaded_content = render_file_tree(folder_items, colors, STYLES,
|
|
1562
1766
|
level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
|
|
1563
|
-
parent_path=folder_rel_path
|
|
1767
|
+
parent_path=folder_rel_path,
|
|
1768
|
+
expanded_folders=expanded_folders,
|
|
1769
|
+
workspace_root=workspace_root)
|
|
1564
1770
|
new_children_content.append(loaded_content if loaded_content else current_content)
|
|
1565
1771
|
except Exception as e:
|
|
1566
1772
|
print(f"Error loading folder {folder_rel_path}: {e}")
|
|
@@ -1597,14 +1803,23 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
|
|
|
1597
1803
|
else:
|
|
1598
1804
|
new_icon_styles.append(current_icon_style)
|
|
1599
1805
|
|
|
1600
|
-
|
|
1806
|
+
# Update expanded folders list
|
|
1807
|
+
new_expanded_folders = list(expanded_folders)
|
|
1808
|
+
if will_expand is not None:
|
|
1809
|
+
if will_expand and clicked_path not in new_expanded_folders:
|
|
1810
|
+
new_expanded_folders.append(clicked_path)
|
|
1811
|
+
elif not will_expand and clicked_path in new_expanded_folders:
|
|
1812
|
+
new_expanded_folders.remove(clicked_path)
|
|
1813
|
+
|
|
1814
|
+
return new_children_styles, new_icon_styles, new_children_content, new_expanded_folders
|
|
1601
1815
|
|
|
1602
1816
|
|
|
1603
1817
|
# Enter folder callback - triggered by double-clicking folder name (changes workspace root)
|
|
1604
1818
|
@app.callback(
|
|
1605
1819
|
[Output("current-workspace-path", "data"),
|
|
1606
1820
|
Output("workspace-breadcrumb", "children"),
|
|
1607
|
-
Output("file-tree", "children", allow_duplicate=True)
|
|
1821
|
+
Output("file-tree", "children", allow_duplicate=True),
|
|
1822
|
+
Output("expanded-folders", "data", allow_duplicate=True)],
|
|
1608
1823
|
[Input({"type": "folder-select", "path": ALL}, "n_clicks"),
|
|
1609
1824
|
Input("breadcrumb-root", "n_clicks"),
|
|
1610
1825
|
Input({"type": "breadcrumb-segment", "index": ALL}, "n_clicks")],
|
|
@@ -1720,13 +1935,14 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
|
|
|
1720
1935
|
else:
|
|
1721
1936
|
workspace_full_path = workspace_root / new_path if new_path else workspace_root
|
|
1722
1937
|
|
|
1723
|
-
# Render new file tree
|
|
1938
|
+
# Render new file tree (reset expanded folders when navigating)
|
|
1724
1939
|
file_tree = render_file_tree(
|
|
1725
1940
|
build_file_tree(workspace_full_path, workspace_full_path),
|
|
1726
|
-
colors, STYLES
|
|
1941
|
+
colors, STYLES,
|
|
1942
|
+
workspace_root=workspace_root
|
|
1727
1943
|
)
|
|
1728
1944
|
|
|
1729
|
-
return new_path, breadcrumb_children, file_tree
|
|
1945
|
+
return new_path, breadcrumb_children, file_tree, [] # Reset expanded folders
|
|
1730
1946
|
|
|
1731
1947
|
|
|
1732
1948
|
# File click - open modal
|
|
@@ -1805,23 +2021,342 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme, session_id):
|
|
|
1805
2021
|
colors = get_colors(theme or "light")
|
|
1806
2022
|
content, is_text, error = read_file_content(workspace_root, file_path)
|
|
1807
2023
|
filename = Path(file_path).name
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2024
|
+
file_ext = Path(file_path).suffix.lower()
|
|
2025
|
+
|
|
2026
|
+
# Define file type categories for binary previews
|
|
2027
|
+
image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp'}
|
|
2028
|
+
pdf_exts = {'.pdf'}
|
|
2029
|
+
|
|
2030
|
+
# Check for binary preview types first
|
|
2031
|
+
if file_ext in image_exts | pdf_exts:
|
|
2032
|
+
b64, _, mime = get_file_download_data(workspace_root, file_path)
|
|
2033
|
+
if b64:
|
|
2034
|
+
data_url = f"data:{mime};base64,{b64}"
|
|
2035
|
+
|
|
2036
|
+
if file_ext in image_exts:
|
|
2037
|
+
# Image preview
|
|
2038
|
+
modal_content = html.Div([
|
|
2039
|
+
html.Img(
|
|
2040
|
+
src=data_url,
|
|
2041
|
+
style={
|
|
2042
|
+
"maxWidth": "100%",
|
|
2043
|
+
"maxHeight": "80vh",
|
|
2044
|
+
"display": "block",
|
|
2045
|
+
"margin": "0 auto",
|
|
2046
|
+
"borderRadius": "4px",
|
|
2047
|
+
}
|
|
2048
|
+
)
|
|
2049
|
+
], style={"textAlign": "center"})
|
|
2050
|
+
|
|
2051
|
+
elif file_ext in pdf_exts:
|
|
2052
|
+
# PDF preview via embed
|
|
2053
|
+
modal_content = html.Embed(
|
|
2054
|
+
src=data_url,
|
|
2055
|
+
type="application/pdf",
|
|
2056
|
+
style={
|
|
2057
|
+
"width": "100%",
|
|
2058
|
+
"height": "80vh",
|
|
2059
|
+
"borderRadius": "4px",
|
|
2060
|
+
}
|
|
2061
|
+
)
|
|
2062
|
+
else:
|
|
2063
|
+
# Failed to read binary file
|
|
2064
|
+
modal_content = html.Div([
|
|
2065
|
+
html.P("Failed to load file preview", style={
|
|
2066
|
+
"color": colors["text_muted"],
|
|
2067
|
+
"textAlign": "center",
|
|
2068
|
+
"padding": "40px",
|
|
2069
|
+
}),
|
|
2070
|
+
html.P("Click Download to save the file.", style={
|
|
2071
|
+
"color": colors["text_muted"],
|
|
2072
|
+
"textAlign": "center",
|
|
2073
|
+
"fontSize": "13px",
|
|
2074
|
+
})
|
|
2075
|
+
])
|
|
2076
|
+
|
|
2077
|
+
elif is_text and content:
|
|
2078
|
+
# HTML files get rendered preview
|
|
2079
|
+
if file_ext in ('.html', '.htm'):
|
|
2080
|
+
modal_content = html.Div([
|
|
2081
|
+
# Tab buttons for switching views
|
|
2082
|
+
html.Div([
|
|
2083
|
+
html.Button("Preview", id="html-preview-tab", n_clicks=0,
|
|
2084
|
+
className="html-tab-btn html-tab-active",
|
|
2085
|
+
style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
|
|
2086
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2087
|
+
"background": colors["accent"], "color": "#fff"}),
|
|
2088
|
+
html.Button("Source", id="html-source-tab", n_clicks=0,
|
|
2089
|
+
className="html-tab-btn",
|
|
2090
|
+
style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
|
|
2091
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2092
|
+
"background": "transparent", "color": colors["text_primary"]}),
|
|
2093
|
+
], style={"marginBottom": "12px", "display": "flex"}),
|
|
2094
|
+
# Preview iframe (default visible)
|
|
2095
|
+
html.Iframe(
|
|
2096
|
+
srcDoc=content,
|
|
2097
|
+
style={
|
|
2098
|
+
"width": "100%",
|
|
2099
|
+
"height": "80vh",
|
|
2100
|
+
"border": f"1px solid {colors['border']}",
|
|
2101
|
+
"borderRadius": "4px",
|
|
2102
|
+
"background": "#fff",
|
|
2103
|
+
},
|
|
2104
|
+
id="html-preview-frame"
|
|
2105
|
+
),
|
|
2106
|
+
# Source code (hidden by default)
|
|
2107
|
+
html.Pre(
|
|
2108
|
+
content,
|
|
2109
|
+
id="html-source-code",
|
|
2110
|
+
style={
|
|
2111
|
+
"display": "none",
|
|
2112
|
+
"background": colors["bg_tertiary"],
|
|
2113
|
+
"padding": "16px",
|
|
2114
|
+
"fontSize": "12px",
|
|
2115
|
+
"fontFamily": "'IBM Plex Mono', monospace",
|
|
2116
|
+
"overflow": "auto",
|
|
2117
|
+
"maxHeight": "80vh",
|
|
2118
|
+
"whiteSpace": "pre-wrap",
|
|
2119
|
+
"wordBreak": "break-word",
|
|
2120
|
+
"margin": "0",
|
|
2121
|
+
"color": colors["text_primary"],
|
|
2122
|
+
"border": f"1px solid {colors['border']}",
|
|
2123
|
+
"borderRadius": "4px",
|
|
2124
|
+
}
|
|
2125
|
+
)
|
|
2126
|
+
])
|
|
2127
|
+
elif file_ext == '.json':
|
|
2128
|
+
# Try to parse as Plotly JSON figure
|
|
2129
|
+
plotly_figure = None
|
|
2130
|
+
try:
|
|
2131
|
+
data = json.loads(content)
|
|
2132
|
+
# Check if it looks like a Plotly figure (has 'data' key with list)
|
|
2133
|
+
if isinstance(data, dict) and 'data' in data and isinstance(data['data'], list):
|
|
2134
|
+
plotly_figure = data
|
|
2135
|
+
except (json.JSONDecodeError, KeyError):
|
|
2136
|
+
pass
|
|
2137
|
+
|
|
2138
|
+
if plotly_figure:
|
|
2139
|
+
# Render as interactive Plotly chart with source toggle
|
|
2140
|
+
modal_content = html.Div([
|
|
2141
|
+
# Tab buttons for switching views
|
|
2142
|
+
html.Div([
|
|
2143
|
+
html.Button("Chart", id="html-preview-tab", n_clicks=0,
|
|
2144
|
+
className="html-tab-btn html-tab-active",
|
|
2145
|
+
style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
|
|
2146
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2147
|
+
"background": colors["accent"], "color": "#fff"}),
|
|
2148
|
+
html.Button("JSON", id="html-source-tab", n_clicks=0,
|
|
2149
|
+
className="html-tab-btn",
|
|
2150
|
+
style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
|
|
2151
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2152
|
+
"background": "transparent", "color": colors["text_primary"]}),
|
|
2153
|
+
], style={"marginBottom": "12px", "display": "flex"}),
|
|
2154
|
+
# Plotly chart (default visible)
|
|
2155
|
+
html.Div([
|
|
2156
|
+
dcc.Graph(
|
|
2157
|
+
figure=plotly_figure,
|
|
2158
|
+
style={"height": "75vh"},
|
|
2159
|
+
config={"displayModeBar": True, "responsive": True}
|
|
2160
|
+
)
|
|
2161
|
+
], id="html-preview-frame", style={
|
|
2162
|
+
"border": f"1px solid {colors['border']}",
|
|
2163
|
+
"borderRadius": "4px",
|
|
2164
|
+
"background": "#fff",
|
|
2165
|
+
}),
|
|
2166
|
+
# JSON source (hidden by default)
|
|
2167
|
+
html.Pre(
|
|
2168
|
+
json.dumps(plotly_figure, indent=2),
|
|
2169
|
+
id="html-source-code",
|
|
2170
|
+
style={
|
|
2171
|
+
"display": "none",
|
|
2172
|
+
"background": colors["bg_tertiary"],
|
|
2173
|
+
"padding": "16px",
|
|
2174
|
+
"fontSize": "12px",
|
|
2175
|
+
"fontFamily": "'IBM Plex Mono', monospace",
|
|
2176
|
+
"overflow": "auto",
|
|
2177
|
+
"maxHeight": "80vh",
|
|
2178
|
+
"whiteSpace": "pre-wrap",
|
|
2179
|
+
"wordBreak": "break-word",
|
|
2180
|
+
"margin": "0",
|
|
2181
|
+
"color": colors["text_primary"],
|
|
2182
|
+
"border": f"1px solid {colors['border']}",
|
|
2183
|
+
"borderRadius": "4px",
|
|
2184
|
+
}
|
|
2185
|
+
)
|
|
2186
|
+
])
|
|
2187
|
+
else:
|
|
2188
|
+
# Regular JSON - show formatted
|
|
2189
|
+
try:
|
|
2190
|
+
formatted = json.dumps(json.loads(content), indent=2)
|
|
2191
|
+
except json.JSONDecodeError:
|
|
2192
|
+
formatted = content
|
|
2193
|
+
modal_content = html.Pre(
|
|
2194
|
+
formatted,
|
|
2195
|
+
style={
|
|
2196
|
+
"background": colors["bg_tertiary"],
|
|
2197
|
+
"padding": "16px",
|
|
2198
|
+
"fontSize": "12px",
|
|
2199
|
+
"fontFamily": "'IBM Plex Mono', monospace",
|
|
2200
|
+
"overflow": "auto",
|
|
2201
|
+
"maxHeight": "80vh",
|
|
2202
|
+
"whiteSpace": "pre-wrap",
|
|
2203
|
+
"wordBreak": "break-word",
|
|
2204
|
+
"margin": "0",
|
|
2205
|
+
"color": colors["text_primary"],
|
|
2206
|
+
}
|
|
2207
|
+
)
|
|
2208
|
+
elif file_ext in ('.csv', '.tsv'):
|
|
2209
|
+
# CSV/TSV files - render as table with raw view option
|
|
2210
|
+
import io as _io
|
|
2211
|
+
try:
|
|
2212
|
+
import pandas as pd
|
|
2213
|
+
sep = '\t' if file_ext == '.tsv' else ','
|
|
2214
|
+
df = pd.read_csv(_io.StringIO(content), sep=sep)
|
|
2215
|
+
|
|
2216
|
+
# Pagination settings
|
|
2217
|
+
rows_per_page = 50
|
|
2218
|
+
total_rows = len(df)
|
|
2219
|
+
total_pages = max(1, (total_rows + rows_per_page - 1) // rows_per_page)
|
|
2220
|
+
current_page = 0
|
|
2221
|
+
|
|
2222
|
+
# Create table preview (first page)
|
|
2223
|
+
start_idx = current_page * rows_per_page
|
|
2224
|
+
end_idx = min(start_idx + rows_per_page, total_rows)
|
|
2225
|
+
preview_df = df.iloc[start_idx:end_idx]
|
|
2226
|
+
|
|
2227
|
+
# Row info for display
|
|
2228
|
+
if total_rows > rows_per_page:
|
|
2229
|
+
row_info = f"Rows {start_idx + 1}-{end_idx} of {total_rows}"
|
|
2230
|
+
else:
|
|
2231
|
+
row_info = f"{total_rows} rows"
|
|
2232
|
+
|
|
2233
|
+
modal_content = html.Div([
|
|
2234
|
+
# Tab buttons for switching views
|
|
2235
|
+
html.Div([
|
|
2236
|
+
html.Button("Table", id="html-preview-tab", n_clicks=0,
|
|
2237
|
+
className="html-tab-btn html-tab-active",
|
|
2238
|
+
style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
|
|
2239
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2240
|
+
"background": colors["accent"], "color": "#fff"}),
|
|
2241
|
+
html.Button("Raw", id="html-source-tab", n_clicks=0,
|
|
2242
|
+
className="html-tab-btn",
|
|
2243
|
+
style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
|
|
2244
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2245
|
+
"background": "transparent", "color": colors["text_primary"]}),
|
|
2246
|
+
], style={"marginBottom": "12px", "display": "flex"}),
|
|
2247
|
+
# Row count info and pagination controls
|
|
2248
|
+
html.Div([
|
|
2249
|
+
html.Span(f"{len(df.columns)} columns, {row_info}", id="csv-row-info", style={
|
|
2250
|
+
"fontSize": "12px",
|
|
2251
|
+
"color": colors["text_muted"],
|
|
2252
|
+
}),
|
|
2253
|
+
# Pagination controls (only show if more than one page)
|
|
2254
|
+
html.Div([
|
|
2255
|
+
html.Button("◀", id="csv-prev-page", n_clicks=0,
|
|
2256
|
+
disabled=current_page == 0,
|
|
2257
|
+
style={
|
|
2258
|
+
"padding": "4px 8px", "border": f"1px solid {colors['border']}",
|
|
2259
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2260
|
+
"background": "transparent", "color": colors["text_primary"],
|
|
2261
|
+
"marginRight": "8px", "fontSize": "12px",
|
|
2262
|
+
}),
|
|
2263
|
+
html.Span(f"Page {current_page + 1} of {total_pages}", id="csv-page-info",
|
|
2264
|
+
style={"fontSize": "12px", "color": colors["text_primary"]}),
|
|
2265
|
+
html.Button("▶", id="csv-next-page", n_clicks=0,
|
|
2266
|
+
disabled=current_page >= total_pages - 1,
|
|
2267
|
+
style={
|
|
2268
|
+
"padding": "4px 8px", "border": f"1px solid {colors['border']}",
|
|
2269
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2270
|
+
"background": "transparent", "color": colors["text_primary"],
|
|
2271
|
+
"marginLeft": "8px", "fontSize": "12px",
|
|
2272
|
+
}),
|
|
2273
|
+
], style={"display": "flex" if total_pages > 1 else "none", "alignItems": "center"}),
|
|
2274
|
+
], style={"display": "flex", "justifyContent": "space-between", "alignItems": "center", "marginBottom": "8px"}),
|
|
2275
|
+
# Store CSV data for pagination
|
|
2276
|
+
dcc.Store(id="csv-data-store", data={
|
|
2277
|
+
"content": content,
|
|
2278
|
+
"sep": sep,
|
|
2279
|
+
"total_rows": total_rows,
|
|
2280
|
+
"total_pages": total_pages,
|
|
2281
|
+
"rows_per_page": rows_per_page,
|
|
2282
|
+
"current_page": current_page,
|
|
2283
|
+
}),
|
|
2284
|
+
# Table preview (default visible)
|
|
2285
|
+
html.Div([
|
|
2286
|
+
dcc.Markdown(
|
|
2287
|
+
preview_df.to_html(index=False, classes="csv-preview-table"),
|
|
2288
|
+
dangerously_allow_html=True,
|
|
2289
|
+
style={"overflow": "auto"}
|
|
2290
|
+
)
|
|
2291
|
+
], id="html-preview-frame", className="csv-table-container", style={
|
|
2292
|
+
"border": f"1px solid {colors['border']}",
|
|
2293
|
+
"borderRadius": "4px",
|
|
2294
|
+
"background": colors["bg_secondary"],
|
|
2295
|
+
"maxHeight": "65vh",
|
|
2296
|
+
"overflow": "auto",
|
|
2297
|
+
}),
|
|
2298
|
+
# Raw CSV (hidden by default)
|
|
2299
|
+
html.Pre(
|
|
2300
|
+
content,
|
|
2301
|
+
id="html-source-code",
|
|
2302
|
+
style={
|
|
2303
|
+
"display": "none",
|
|
2304
|
+
"background": colors["bg_tertiary"],
|
|
2305
|
+
"padding": "16px",
|
|
2306
|
+
"fontSize": "12px",
|
|
2307
|
+
"fontFamily": "'IBM Plex Mono', monospace",
|
|
2308
|
+
"overflow": "auto",
|
|
2309
|
+
"maxHeight": "80vh",
|
|
2310
|
+
"whiteSpace": "pre-wrap",
|
|
2311
|
+
"wordBreak": "break-word",
|
|
2312
|
+
"margin": "0",
|
|
2313
|
+
"color": colors["text_primary"],
|
|
2314
|
+
"border": f"1px solid {colors['border']}",
|
|
2315
|
+
"borderRadius": "4px",
|
|
2316
|
+
}
|
|
2317
|
+
)
|
|
2318
|
+
])
|
|
2319
|
+
except Exception as e:
|
|
2320
|
+
# Fall back to raw text if parsing fails
|
|
2321
|
+
modal_content = html.Div([
|
|
2322
|
+
html.Div(f"Could not parse as CSV: {e}", style={
|
|
2323
|
+
"fontSize": "12px",
|
|
2324
|
+
"color": colors["text_muted"],
|
|
2325
|
+
"marginBottom": "8px",
|
|
2326
|
+
}),
|
|
2327
|
+
html.Pre(
|
|
2328
|
+
content,
|
|
2329
|
+
style={
|
|
2330
|
+
"background": colors["bg_tertiary"],
|
|
2331
|
+
"padding": "16px",
|
|
2332
|
+
"fontSize": "12px",
|
|
2333
|
+
"fontFamily": "'IBM Plex Mono', monospace",
|
|
2334
|
+
"overflow": "auto",
|
|
2335
|
+
"maxHeight": "80vh",
|
|
2336
|
+
"whiteSpace": "pre-wrap",
|
|
2337
|
+
"wordBreak": "break-word",
|
|
2338
|
+
"margin": "0",
|
|
2339
|
+
"color": colors["text_primary"],
|
|
2340
|
+
}
|
|
2341
|
+
)
|
|
2342
|
+
])
|
|
2343
|
+
else:
|
|
2344
|
+
# Regular text files
|
|
2345
|
+
modal_content = html.Pre(
|
|
2346
|
+
content,
|
|
2347
|
+
style={
|
|
2348
|
+
"background": colors["bg_tertiary"],
|
|
2349
|
+
"padding": "16px",
|
|
2350
|
+
"fontSize": "12px",
|
|
2351
|
+
"fontFamily": "'IBM Plex Mono', monospace",
|
|
2352
|
+
"overflow": "auto",
|
|
2353
|
+
"maxHeight": "80vh",
|
|
2354
|
+
"whiteSpace": "pre-wrap",
|
|
2355
|
+
"wordBreak": "break-word",
|
|
2356
|
+
"margin": "0",
|
|
2357
|
+
"color": colors["text_primary"],
|
|
2358
|
+
}
|
|
2359
|
+
)
|
|
1825
2360
|
else:
|
|
1826
2361
|
modal_content = html.Div([
|
|
1827
2362
|
html.P(error or "Cannot display file", style={
|
|
@@ -1870,6 +2405,142 @@ def download_from_modal(n_clicks, file_path, session_id):
|
|
|
1870
2405
|
return dict(content=b64, filename=filename, base64=True, type=mime)
|
|
1871
2406
|
|
|
1872
2407
|
|
|
2408
|
+
# HTML preview/source tab switching
|
|
2409
|
+
@app.callback(
|
|
2410
|
+
[Output("html-preview-frame", "style"),
|
|
2411
|
+
Output("html-source-code", "style"),
|
|
2412
|
+
Output("html-preview-tab", "style"),
|
|
2413
|
+
Output("html-source-tab", "style")],
|
|
2414
|
+
[Input("html-preview-tab", "n_clicks"),
|
|
2415
|
+
Input("html-source-tab", "n_clicks")],
|
|
2416
|
+
[State("theme-store", "data"),
|
|
2417
|
+
State("html-preview-frame", "style"),
|
|
2418
|
+
State("html-source-code", "style")],
|
|
2419
|
+
prevent_initial_call=True
|
|
2420
|
+
)
|
|
2421
|
+
def toggle_html_view(preview_clicks, source_clicks, theme, current_preview_style, current_source_style):
|
|
2422
|
+
"""Toggle between HTML preview and source code view."""
|
|
2423
|
+
ctx = callback_context
|
|
2424
|
+
if not ctx.triggered:
|
|
2425
|
+
raise PreventUpdate
|
|
2426
|
+
|
|
2427
|
+
colors = get_colors(theme or "light")
|
|
2428
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
2429
|
+
|
|
2430
|
+
# Preserve current styles and only update display property
|
|
2431
|
+
# This ensures background colors set by the modal content are preserved
|
|
2432
|
+
preview_frame_style = current_preview_style.copy() if current_preview_style else {}
|
|
2433
|
+
source_code_style = current_source_style.copy() if current_source_style else {}
|
|
2434
|
+
|
|
2435
|
+
# Update theme-sensitive properties
|
|
2436
|
+
source_code_style.update({
|
|
2437
|
+
"background": colors["bg_tertiary"],
|
|
2438
|
+
"color": colors["text_primary"],
|
|
2439
|
+
"border": f"1px solid {colors['border']}",
|
|
2440
|
+
})
|
|
2441
|
+
|
|
2442
|
+
active_btn_style = {
|
|
2443
|
+
"marginRight": "8px", "padding": "6px 12px", "border": "none",
|
|
2444
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2445
|
+
"background": colors["accent"], "color": "#fff"
|
|
2446
|
+
}
|
|
2447
|
+
inactive_btn_style = {
|
|
2448
|
+
"padding": "6px 12px", "border": f"1px solid {colors['border']}",
|
|
2449
|
+
"borderRadius": "4px", "cursor": "pointer",
|
|
2450
|
+
"background": "transparent", "color": colors["text_primary"]
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
if triggered_id == "html-source-tab":
|
|
2454
|
+
# Show source, hide preview
|
|
2455
|
+
preview_frame_style["display"] = "none"
|
|
2456
|
+
source_code_style["display"] = "block"
|
|
2457
|
+
return preview_frame_style, source_code_style, {**inactive_btn_style, "marginRight": "8px"}, active_btn_style
|
|
2458
|
+
else:
|
|
2459
|
+
# Show preview, hide source (default)
|
|
2460
|
+
preview_frame_style["display"] = "block"
|
|
2461
|
+
source_code_style["display"] = "none"
|
|
2462
|
+
return preview_frame_style, source_code_style, active_btn_style, {**inactive_btn_style}
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
# CSV pagination
|
|
2466
|
+
@app.callback(
|
|
2467
|
+
[Output("html-preview-frame", "children", allow_duplicate=True),
|
|
2468
|
+
Output("csv-row-info", "children"),
|
|
2469
|
+
Output("csv-page-info", "children"),
|
|
2470
|
+
Output("csv-prev-page", "disabled"),
|
|
2471
|
+
Output("csv-next-page", "disabled"),
|
|
2472
|
+
Output("csv-data-store", "data")],
|
|
2473
|
+
[Input("csv-prev-page", "n_clicks"),
|
|
2474
|
+
Input("csv-next-page", "n_clicks")],
|
|
2475
|
+
[State("csv-data-store", "data"),
|
|
2476
|
+
State("theme-store", "data")],
|
|
2477
|
+
prevent_initial_call=True
|
|
2478
|
+
)
|
|
2479
|
+
def paginate_csv(prev_clicks, next_clicks, csv_data, theme):
|
|
2480
|
+
"""Handle CSV pagination."""
|
|
2481
|
+
ctx = callback_context
|
|
2482
|
+
if not ctx.triggered or not csv_data:
|
|
2483
|
+
raise PreventUpdate
|
|
2484
|
+
|
|
2485
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
2486
|
+
|
|
2487
|
+
import io as _io
|
|
2488
|
+
import pandas as pd
|
|
2489
|
+
|
|
2490
|
+
# Get current state
|
|
2491
|
+
content = csv_data.get("content", "")
|
|
2492
|
+
sep = csv_data.get("sep", ",")
|
|
2493
|
+
total_rows = csv_data.get("total_rows", 0)
|
|
2494
|
+
total_pages = csv_data.get("total_pages", 1)
|
|
2495
|
+
rows_per_page = csv_data.get("rows_per_page", 50)
|
|
2496
|
+
current_page = csv_data.get("current_page", 0)
|
|
2497
|
+
|
|
2498
|
+
# Update page based on which button was clicked
|
|
2499
|
+
if triggered_id == "csv-prev-page" and current_page > 0:
|
|
2500
|
+
current_page -= 1
|
|
2501
|
+
elif triggered_id == "csv-next-page" and current_page < total_pages - 1:
|
|
2502
|
+
current_page += 1
|
|
2503
|
+
else:
|
|
2504
|
+
raise PreventUpdate
|
|
2505
|
+
|
|
2506
|
+
# Parse CSV and get the page slice
|
|
2507
|
+
try:
|
|
2508
|
+
df = pd.read_csv(_io.StringIO(content), sep=sep)
|
|
2509
|
+
start_idx = current_page * rows_per_page
|
|
2510
|
+
end_idx = min(start_idx + rows_per_page, total_rows)
|
|
2511
|
+
preview_df = df.iloc[start_idx:end_idx]
|
|
2512
|
+
|
|
2513
|
+
# Generate row info
|
|
2514
|
+
if total_rows > rows_per_page:
|
|
2515
|
+
row_info = f"{len(df.columns)} columns, Rows {start_idx + 1}-{end_idx} of {total_rows}"
|
|
2516
|
+
else:
|
|
2517
|
+
row_info = f"{len(df.columns)} columns, {total_rows} rows"
|
|
2518
|
+
|
|
2519
|
+
# Generate table HTML
|
|
2520
|
+
table_html = dcc.Markdown(
|
|
2521
|
+
preview_df.to_html(index=False, classes="csv-preview-table"),
|
|
2522
|
+
dangerously_allow_html=True,
|
|
2523
|
+
style={"overflow": "auto"}
|
|
2524
|
+
)
|
|
2525
|
+
|
|
2526
|
+
# Update pagination state
|
|
2527
|
+
updated_csv_data = {
|
|
2528
|
+
**csv_data,
|
|
2529
|
+
"current_page": current_page,
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
return (
|
|
2533
|
+
table_html,
|
|
2534
|
+
row_info,
|
|
2535
|
+
f"Page {current_page + 1} of {total_pages}",
|
|
2536
|
+
current_page == 0, # prev disabled
|
|
2537
|
+
current_page >= total_pages - 1, # next disabled
|
|
2538
|
+
updated_csv_data
|
|
2539
|
+
)
|
|
2540
|
+
except Exception:
|
|
2541
|
+
raise PreventUpdate
|
|
2542
|
+
|
|
2543
|
+
|
|
1873
2544
|
# Open terminal
|
|
1874
2545
|
@app.callback(
|
|
1875
2546
|
Output("open-terminal-btn", "n_clicks"),
|
|
@@ -1922,13 +2593,15 @@ def open_terminal(n_clicks):
|
|
|
1922
2593
|
[State("current-workspace-path", "data"),
|
|
1923
2594
|
State("theme-store", "data"),
|
|
1924
2595
|
State("collapsed-canvas-items", "data"),
|
|
1925
|
-
State("session-id", "data")
|
|
2596
|
+
State("session-id", "data"),
|
|
2597
|
+
State("expanded-folders", "data")],
|
|
1926
2598
|
prevent_initial_call=True
|
|
1927
2599
|
)
|
|
1928
|
-
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id):
|
|
2600
|
+
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id, expanded_folders):
|
|
1929
2601
|
"""Refresh both file tree and canvas content."""
|
|
1930
2602
|
colors = get_colors(theme or "light")
|
|
1931
2603
|
collapsed_ids = collapsed_ids or []
|
|
2604
|
+
expanded_folders = expanded_folders or []
|
|
1932
2605
|
|
|
1933
2606
|
# Get workspace for this session (virtual or physical)
|
|
1934
2607
|
workspace_root = get_workspace_for_session(session_id)
|
|
@@ -1939,8 +2612,8 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_i
|
|
|
1939
2612
|
else:
|
|
1940
2613
|
current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
|
|
1941
2614
|
|
|
1942
|
-
# Refresh file tree for current workspace
|
|
1943
|
-
file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
2615
|
+
# Refresh file tree for current workspace, preserving expanded folders
|
|
2616
|
+
file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
|
|
1944
2617
|
|
|
1945
2618
|
# Re-render canvas from current in-memory state (don't reload from file)
|
|
1946
2619
|
# This preserves canvas items that may not have been exported to .canvas/canvas.md yet
|
|
@@ -1960,15 +2633,17 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_i
|
|
|
1960
2633
|
[State("file-upload-sidebar", "filename"),
|
|
1961
2634
|
State("current-workspace-path", "data"),
|
|
1962
2635
|
State("theme-store", "data"),
|
|
1963
|
-
State("session-id", "data")
|
|
2636
|
+
State("session-id", "data"),
|
|
2637
|
+
State("expanded-folders", "data")],
|
|
1964
2638
|
prevent_initial_call=True
|
|
1965
2639
|
)
|
|
1966
|
-
def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id):
|
|
2640
|
+
def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id, expanded_folders):
|
|
1967
2641
|
"""Handle file uploads from sidebar button to current workspace."""
|
|
1968
2642
|
if not contents:
|
|
1969
2643
|
raise PreventUpdate
|
|
1970
2644
|
|
|
1971
2645
|
colors = get_colors(theme or "light")
|
|
2646
|
+
expanded_folders = expanded_folders or []
|
|
1972
2647
|
|
|
1973
2648
|
# Get workspace for this session (virtual or physical)
|
|
1974
2649
|
workspace_root = get_workspace_for_session(session_id)
|
|
@@ -1991,7 +2666,7 @@ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session
|
|
|
1991
2666
|
except Exception as e:
|
|
1992
2667
|
print(f"Upload error: {e}")
|
|
1993
2668
|
|
|
1994
|
-
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
2669
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
|
|
1995
2670
|
|
|
1996
2671
|
|
|
1997
2672
|
# Create folder modal - open
|
|
@@ -2034,15 +2709,17 @@ def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_op
|
|
|
2034
2709
|
[State("new-folder-name", "value"),
|
|
2035
2710
|
State("current-workspace-path", "data"),
|
|
2036
2711
|
State("theme-store", "data"),
|
|
2037
|
-
State("session-id", "data")
|
|
2712
|
+
State("session-id", "data"),
|
|
2713
|
+
State("expanded-folders", "data")],
|
|
2038
2714
|
prevent_initial_call=True
|
|
2039
2715
|
)
|
|
2040
|
-
def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
|
|
2716
|
+
def create_folder(n_clicks, folder_name, current_workspace, theme, session_id, expanded_folders):
|
|
2041
2717
|
"""Create a new folder in the current workspace directory."""
|
|
2042
2718
|
if not n_clicks:
|
|
2043
2719
|
raise PreventUpdate
|
|
2044
2720
|
|
|
2045
2721
|
colors = get_colors(theme or "light")
|
|
2722
|
+
expanded_folders = expanded_folders or []
|
|
2046
2723
|
|
|
2047
2724
|
if not folder_name or not folder_name.strip():
|
|
2048
2725
|
return no_update, "Please enter a folder name", no_update
|
|
@@ -2070,7 +2747,7 @@ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
|
|
|
2070
2747
|
|
|
2071
2748
|
try:
|
|
2072
2749
|
folder_path.mkdir(parents=True, exist_ok=False)
|
|
2073
|
-
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES), "", ""
|
|
2750
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root), "", ""
|
|
2074
2751
|
except Exception as e:
|
|
2075
2752
|
return no_update, f"Error creating folder: {e}", no_update
|
|
2076
2753
|
|
|
@@ -2156,15 +2833,17 @@ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids, session
|
|
|
2156
2833
|
[State("current-workspace-path", "data"),
|
|
2157
2834
|
State("theme-store", "data"),
|
|
2158
2835
|
State("session-id", "data"),
|
|
2159
|
-
State("sidebar-view-toggle", "value")
|
|
2836
|
+
State("sidebar-view-toggle", "value"),
|
|
2837
|
+
State("expanded-folders", "data")],
|
|
2160
2838
|
prevent_initial_call=True
|
|
2161
2839
|
)
|
|
2162
|
-
def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, view_value):
|
|
2840
|
+
def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, view_value, expanded_folders):
|
|
2163
2841
|
"""Refresh file tree during agent execution to show newly created files.
|
|
2164
2842
|
|
|
2165
2843
|
This callback runs on each poll interval and refreshes the file tree
|
|
2166
2844
|
so that files created by the agent are visible in real-time.
|
|
2167
2845
|
Only updates when viewing files (not canvas).
|
|
2846
|
+
Preserves expanded folder state across refreshes.
|
|
2168
2847
|
"""
|
|
2169
2848
|
# Only refresh when viewing files panel
|
|
2170
2849
|
if view_value != "files":
|
|
@@ -2180,6 +2859,7 @@ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, vie
|
|
|
2180
2859
|
raise PreventUpdate
|
|
2181
2860
|
|
|
2182
2861
|
colors = get_colors(theme or "light")
|
|
2862
|
+
expanded_folders = expanded_folders or []
|
|
2183
2863
|
|
|
2184
2864
|
# Get workspace for this session (virtual or physical)
|
|
2185
2865
|
workspace_root = get_workspace_for_session(session_id)
|
|
@@ -2190,8 +2870,8 @@ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, vie
|
|
|
2190
2870
|
else:
|
|
2191
2871
|
current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
|
|
2192
2872
|
|
|
2193
|
-
# Refresh file tree
|
|
2194
|
-
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
2873
|
+
# Refresh file tree, preserving expanded folder state
|
|
2874
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders, workspace_root=workspace_root)
|
|
2195
2875
|
|
|
2196
2876
|
|
|
2197
2877
|
# Open clear canvas confirmation modal
|
|
@@ -2477,6 +3157,133 @@ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, co
|
|
|
2477
3157
|
raise PreventUpdate
|
|
2478
3158
|
|
|
2479
3159
|
|
|
3160
|
+
# =============================================================================
|
|
3161
|
+
# ADD DISPLAY_INLINE TO CANVAS CALLBACK
|
|
3162
|
+
# =============================================================================
|
|
3163
|
+
|
|
3164
|
+
@app.callback(
|
|
3165
|
+
[Output("canvas-content", "children", allow_duplicate=True),
|
|
3166
|
+
Output("sidebar-view-toggle", "value", allow_duplicate=True)],
|
|
3167
|
+
Input({"type": "add-display-to-canvas-btn", "index": ALL}, "n_clicks"),
|
|
3168
|
+
[State({"type": "display-inline-data", "index": ALL}, "data"),
|
|
3169
|
+
State("theme-store", "data"),
|
|
3170
|
+
State("collapsed-canvas-items", "data"),
|
|
3171
|
+
State("session-id", "data")],
|
|
3172
|
+
prevent_initial_call=True
|
|
3173
|
+
)
|
|
3174
|
+
def add_display_inline_to_canvas(n_clicks_list, data_list, theme, collapsed_ids, session_id):
|
|
3175
|
+
"""Add a display_inline item to the canvas when the button is clicked.
|
|
3176
|
+
|
|
3177
|
+
This allows users to save inline display items to the canvas for persistent reference.
|
|
3178
|
+
"""
|
|
3179
|
+
from .canvas import generate_canvas_id, export_canvas_to_markdown
|
|
3180
|
+
from datetime import datetime
|
|
3181
|
+
|
|
3182
|
+
# Check if any button was actually clicked
|
|
3183
|
+
if not n_clicks_list or not any(n_clicks_list):
|
|
3184
|
+
raise PreventUpdate
|
|
3185
|
+
|
|
3186
|
+
# Find which button was clicked
|
|
3187
|
+
ctx = callback_context
|
|
3188
|
+
if not ctx.triggered:
|
|
3189
|
+
raise PreventUpdate
|
|
3190
|
+
|
|
3191
|
+
triggered = ctx.triggered[0]
|
|
3192
|
+
triggered_id = triggered["prop_id"]
|
|
3193
|
+
|
|
3194
|
+
# Parse the pattern-matching ID to get the index
|
|
3195
|
+
try:
|
|
3196
|
+
# Format: {"type":"add-display-to-canvas-btn","index":"abc123"}.n_clicks
|
|
3197
|
+
id_part = triggered_id.rsplit(".", 1)[0]
|
|
3198
|
+
id_dict = json.loads(id_part)
|
|
3199
|
+
clicked_index = id_dict.get("index")
|
|
3200
|
+
except (json.JSONDecodeError, KeyError, AttributeError):
|
|
3201
|
+
raise PreventUpdate
|
|
3202
|
+
|
|
3203
|
+
if not clicked_index:
|
|
3204
|
+
raise PreventUpdate
|
|
3205
|
+
|
|
3206
|
+
# Find the corresponding data
|
|
3207
|
+
display_data = None
|
|
3208
|
+
for data in data_list:
|
|
3209
|
+
if data and data.get("_item_id") == clicked_index:
|
|
3210
|
+
display_data = data
|
|
3211
|
+
break
|
|
3212
|
+
|
|
3213
|
+
if not display_data:
|
|
3214
|
+
raise PreventUpdate
|
|
3215
|
+
|
|
3216
|
+
colors = get_colors(theme or "light")
|
|
3217
|
+
collapsed_ids = collapsed_ids or []
|
|
3218
|
+
|
|
3219
|
+
# Get workspace for this session (virtual or physical)
|
|
3220
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
3221
|
+
|
|
3222
|
+
# Convert display_inline result to canvas item format
|
|
3223
|
+
display_type = display_data.get("display_type", "text")
|
|
3224
|
+
title = display_data.get("title")
|
|
3225
|
+
data = display_data.get("data")
|
|
3226
|
+
|
|
3227
|
+
# Generate new canvas ID and timestamp
|
|
3228
|
+
canvas_id = generate_canvas_id()
|
|
3229
|
+
created_at = datetime.now().isoformat()
|
|
3230
|
+
|
|
3231
|
+
# Map display_inline types to canvas types
|
|
3232
|
+
canvas_item = {
|
|
3233
|
+
"id": canvas_id,
|
|
3234
|
+
"created_at": created_at,
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
if title:
|
|
3238
|
+
canvas_item["title"] = title
|
|
3239
|
+
|
|
3240
|
+
if display_type == "image":
|
|
3241
|
+
canvas_item["type"] = "image"
|
|
3242
|
+
canvas_item["data"] = data # base64 image data
|
|
3243
|
+
elif display_type == "plotly":
|
|
3244
|
+
canvas_item["type"] = "plotly"
|
|
3245
|
+
canvas_item["data"] = data # Plotly JSON
|
|
3246
|
+
elif display_type == "dataframe":
|
|
3247
|
+
canvas_item["type"] = "dataframe"
|
|
3248
|
+
canvas_item["data"] = display_data.get("csv", {}).get("data", [])
|
|
3249
|
+
canvas_item["columns"] = display_data.get("csv", {}).get("columns", [])
|
|
3250
|
+
canvas_item["html"] = display_data.get("csv", {}).get("html", "")
|
|
3251
|
+
elif display_type == "pdf":
|
|
3252
|
+
canvas_item["type"] = "pdf"
|
|
3253
|
+
canvas_item["data"] = data # base64 PDF data
|
|
3254
|
+
canvas_item["mime_type"] = display_data.get("mime_type", "application/pdf")
|
|
3255
|
+
elif display_type == "html":
|
|
3256
|
+
canvas_item["type"] = "markdown"
|
|
3257
|
+
canvas_item["data"] = data # Store HTML as markdown (will render)
|
|
3258
|
+
elif display_type == "json":
|
|
3259
|
+
canvas_item["type"] = "markdown"
|
|
3260
|
+
canvas_item["data"] = f"```json\n{json.dumps(data, indent=2)}\n```"
|
|
3261
|
+
else:
|
|
3262
|
+
# text or other
|
|
3263
|
+
canvas_item["type"] = "markdown"
|
|
3264
|
+
canvas_item["data"] = str(data) if data else ""
|
|
3265
|
+
|
|
3266
|
+
# Add item to canvas (session-specific in virtual FS mode)
|
|
3267
|
+
if USE_VIRTUAL_FS and session_id:
|
|
3268
|
+
current_state = _get_session_state(session_id)
|
|
3269
|
+
with _session_agents_lock:
|
|
3270
|
+
current_state["canvas"].append(canvas_item)
|
|
3271
|
+
canvas_items = current_state["canvas"].copy()
|
|
3272
|
+
else:
|
|
3273
|
+
with _agent_state_lock:
|
|
3274
|
+
_agent_state["canvas"].append(canvas_item)
|
|
3275
|
+
canvas_items = _agent_state["canvas"].copy()
|
|
3276
|
+
|
|
3277
|
+
# Export updated canvas to markdown file
|
|
3278
|
+
try:
|
|
3279
|
+
export_canvas_to_markdown(canvas_items, workspace_root)
|
|
3280
|
+
except Exception as e:
|
|
3281
|
+
print(f"Failed to export canvas after adding display item: {e}")
|
|
3282
|
+
|
|
3283
|
+
# Render updated canvas and switch to canvas view
|
|
3284
|
+
return render_canvas_items(canvas_items, colors, collapsed_ids), "canvas"
|
|
3285
|
+
|
|
3286
|
+
|
|
2480
3287
|
# =============================================================================
|
|
2481
3288
|
# THEME TOGGLE CALLBACK - Using DMC 2.4 forceColorScheme
|
|
2482
3289
|
# =============================================================================
|