cowork-dash 0.1.4__py3-none-any.whl → 0.1.6__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 +7 -4
- cowork_dash/app.py +729 -121
- cowork_dash/assets/app.js +12 -1
- cowork_dash/assets/favicon.ico +0 -0
- cowork_dash/assets/styles.css +144 -2
- cowork_dash/canvas.py +194 -80
- cowork_dash/cli.py +7 -0
- cowork_dash/components.py +194 -104
- cowork_dash/config.py +13 -0
- cowork_dash/file_utils.py +17 -6
- cowork_dash/layout.py +117 -23
- cowork_dash/tools.py +88 -11
- {cowork_dash-0.1.4.dist-info → cowork_dash-0.1.6.dist-info}/METADATA +31 -40
- cowork_dash-0.1.6.dist-info/RECORD +20 -0
- cowork_dash-0.1.4.dist-info/RECORD +0 -19
- {cowork_dash-0.1.4.dist-info → cowork_dash-0.1.6.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.4.dist-info → cowork_dash-0.1.6.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.4.dist-info → cowork_dash-0.1.6.dist-info}/licenses/LICENSE +0 -0
cowork_dash/app.py
CHANGED
|
@@ -4,6 +4,7 @@ import sys
|
|
|
4
4
|
import json
|
|
5
5
|
import base64
|
|
6
6
|
import re
|
|
7
|
+
import copy
|
|
7
8
|
import shutil
|
|
8
9
|
import platform
|
|
9
10
|
import subprocess
|
|
@@ -201,6 +202,7 @@ APP_SUBTITLE = config.APP_SUBTITLE
|
|
|
201
202
|
PORT = config.PORT
|
|
202
203
|
HOST = config.HOST
|
|
203
204
|
DEBUG = config.DEBUG
|
|
205
|
+
WELCOME_MESSAGE = config.WELCOME_MESSAGE
|
|
204
206
|
|
|
205
207
|
# Ensure workspace exists
|
|
206
208
|
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
@@ -288,15 +290,24 @@ _agent_state = {
|
|
|
288
290
|
"interrupt": None, # Track interrupt requests for human-in-the-loop
|
|
289
291
|
"last_update": time.time(),
|
|
290
292
|
"start_time": None, # Track when agent started for response time calculation
|
|
293
|
+
"stop_requested": False, # Flag to request agent stop
|
|
291
294
|
}
|
|
292
295
|
_agent_state_lock = threading.Lock()
|
|
293
296
|
|
|
294
|
-
|
|
297
|
+
|
|
298
|
+
def request_agent_stop():
|
|
299
|
+
"""Request the agent to stop execution."""
|
|
300
|
+
with _agent_state_lock:
|
|
301
|
+
_agent_state["stop_requested"] = True
|
|
302
|
+
_agent_state["last_update"] = time.time()
|
|
303
|
+
|
|
304
|
+
def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None):
|
|
295
305
|
"""Run agent in background thread and update global state in real-time.
|
|
296
306
|
|
|
297
307
|
Args:
|
|
298
308
|
message: User message to send to agent
|
|
299
309
|
resume_data: Optional dict with 'decisions' to resume from interrupt
|
|
310
|
+
workspace_path: Current workspace directory path to inject into agent context
|
|
300
311
|
"""
|
|
301
312
|
if not agent:
|
|
302
313
|
with _agent_state_lock:
|
|
@@ -345,9 +356,24 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
|
|
|
345
356
|
from langgraph.types import Command
|
|
346
357
|
agent_input = Command(resume=resume_data)
|
|
347
358
|
else:
|
|
348
|
-
|
|
359
|
+
# Inject workspace context into the message if available
|
|
360
|
+
if workspace_path:
|
|
361
|
+
context_prefix = f"[Current working directory: {workspace_path}]\n\n"
|
|
362
|
+
message_with_context = context_prefix + message
|
|
363
|
+
else:
|
|
364
|
+
message_with_context = message
|
|
365
|
+
agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
|
|
349
366
|
|
|
350
367
|
for update in agent.stream(agent_input, stream_mode="updates", config=stream_config):
|
|
368
|
+
# Check if stop was requested
|
|
369
|
+
with _agent_state_lock:
|
|
370
|
+
if _agent_state.get("stop_requested"):
|
|
371
|
+
_agent_state["response"] = _agent_state.get("response", "") + "\n\nAgent stopped by user."
|
|
372
|
+
_agent_state["running"] = False
|
|
373
|
+
_agent_state["stop_requested"] = False
|
|
374
|
+
_agent_state["last_update"] = time.time()
|
|
375
|
+
return
|
|
376
|
+
|
|
351
377
|
# Check for interrupt
|
|
352
378
|
if isinstance(update, dict) and "__interrupt__" in update:
|
|
353
379
|
interrupt_value = update["__interrupt__"]
|
|
@@ -477,6 +503,66 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
|
|
|
477
503
|
except Exception as e:
|
|
478
504
|
print(f"Failed to export canvas: {e}")
|
|
479
505
|
|
|
506
|
+
elif last_msg.name == 'update_canvas_item':
|
|
507
|
+
content = last_msg.content
|
|
508
|
+
# Parse the canvas item to update
|
|
509
|
+
if isinstance(content, str):
|
|
510
|
+
try:
|
|
511
|
+
canvas_item = json.loads(content)
|
|
512
|
+
except:
|
|
513
|
+
canvas_item = {"type": "markdown", "data": content}
|
|
514
|
+
elif isinstance(content, dict):
|
|
515
|
+
canvas_item = content
|
|
516
|
+
else:
|
|
517
|
+
canvas_item = {"type": "markdown", "data": str(content)}
|
|
518
|
+
|
|
519
|
+
item_id = canvas_item.get("id")
|
|
520
|
+
if item_id:
|
|
521
|
+
with _agent_state_lock:
|
|
522
|
+
# Find and replace the item with matching ID
|
|
523
|
+
for i, existing in enumerate(_agent_state["canvas"]):
|
|
524
|
+
if existing.get("id") == item_id:
|
|
525
|
+
_agent_state["canvas"][i] = canvas_item
|
|
526
|
+
break
|
|
527
|
+
else:
|
|
528
|
+
# If not found, append as new item
|
|
529
|
+
_agent_state["canvas"].append(canvas_item)
|
|
530
|
+
_agent_state["last_update"] = time.time()
|
|
531
|
+
|
|
532
|
+
# Export to markdown file
|
|
533
|
+
try:
|
|
534
|
+
export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
|
|
535
|
+
except Exception as e:
|
|
536
|
+
print(f"Failed to export canvas: {e}")
|
|
537
|
+
|
|
538
|
+
elif last_msg.name == 'remove_canvas_item':
|
|
539
|
+
content = last_msg.content
|
|
540
|
+
# Parse to get the item ID to remove
|
|
541
|
+
if isinstance(content, str):
|
|
542
|
+
try:
|
|
543
|
+
parsed = json.loads(content)
|
|
544
|
+
item_id = parsed.get("id")
|
|
545
|
+
except:
|
|
546
|
+
item_id = content # Assume string is the ID
|
|
547
|
+
elif isinstance(content, dict):
|
|
548
|
+
item_id = content.get("id")
|
|
549
|
+
else:
|
|
550
|
+
item_id = None
|
|
551
|
+
|
|
552
|
+
if item_id:
|
|
553
|
+
with _agent_state_lock:
|
|
554
|
+
_agent_state["canvas"] = [
|
|
555
|
+
item for item in _agent_state["canvas"]
|
|
556
|
+
if item.get("id") != item_id
|
|
557
|
+
]
|
|
558
|
+
_agent_state["last_update"] = time.time()
|
|
559
|
+
|
|
560
|
+
# Export to markdown file
|
|
561
|
+
try:
|
|
562
|
+
export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
|
|
563
|
+
except Exception as e:
|
|
564
|
+
print(f"Failed to export canvas: {e}")
|
|
565
|
+
|
|
480
566
|
elif last_msg.name in ('execute_cell', 'execute_all_cells'):
|
|
481
567
|
# Extract canvas_items from cell execution results
|
|
482
568
|
content = last_msg.content
|
|
@@ -643,12 +729,13 @@ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
|
643
729
|
|
|
644
730
|
return interrupt_data
|
|
645
731
|
|
|
646
|
-
def call_agent(message: str, resume_data: Dict = None):
|
|
732
|
+
def call_agent(message: str, resume_data: Dict = None, workspace_path: str = None):
|
|
647
733
|
"""Start agent execution in background thread.
|
|
648
734
|
|
|
649
735
|
Args:
|
|
650
736
|
message: User message to send to agent
|
|
651
737
|
resume_data: Optional dict with decisions to resume from interrupt
|
|
738
|
+
workspace_path: Current workspace directory path to inject into agent context
|
|
652
739
|
"""
|
|
653
740
|
# Reset state but preserve canvas - do it all atomically
|
|
654
741
|
with _agent_state_lock:
|
|
@@ -666,10 +753,11 @@ def call_agent(message: str, resume_data: Dict = None):
|
|
|
666
753
|
"interrupt": None, # Clear any previous interrupt
|
|
667
754
|
"last_update": time.time(),
|
|
668
755
|
"start_time": time.time(), # Track when agent started
|
|
756
|
+
"stop_requested": False, # Reset stop flag
|
|
669
757
|
})
|
|
670
758
|
|
|
671
759
|
# Start background thread
|
|
672
|
-
thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data))
|
|
760
|
+
thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data, workspace_path))
|
|
673
761
|
thread.daemon = True
|
|
674
762
|
thread.start()
|
|
675
763
|
|
|
@@ -763,9 +851,36 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
|
|
|
763
851
|
thread.start()
|
|
764
852
|
|
|
765
853
|
def get_agent_state() -> Dict[str, Any]:
|
|
766
|
-
"""Get current agent state (thread-safe).
|
|
854
|
+
"""Get current agent state (thread-safe).
|
|
855
|
+
|
|
856
|
+
Returns a deep copy of mutable collections to prevent race conditions.
|
|
857
|
+
"""
|
|
858
|
+
with _agent_state_lock:
|
|
859
|
+
state = _agent_state.copy()
|
|
860
|
+
# Deep copy mutable collections to prevent race conditions during rendering
|
|
861
|
+
state["tool_calls"] = copy.deepcopy(_agent_state["tool_calls"])
|
|
862
|
+
state["todos"] = copy.deepcopy(_agent_state["todos"])
|
|
863
|
+
state["canvas"] = copy.deepcopy(_agent_state["canvas"])
|
|
864
|
+
return state
|
|
865
|
+
|
|
866
|
+
def reset_agent_state():
|
|
867
|
+
"""Reset agent state for a fresh session (thread-safe).
|
|
868
|
+
|
|
869
|
+
Called on page load to ensure clean state after browser refresh.
|
|
870
|
+
Preserves canvas items loaded from canvas.md.
|
|
871
|
+
"""
|
|
767
872
|
with _agent_state_lock:
|
|
768
|
-
|
|
873
|
+
_agent_state["running"] = False
|
|
874
|
+
_agent_state["thinking"] = ""
|
|
875
|
+
_agent_state["todos"] = []
|
|
876
|
+
_agent_state["tool_calls"] = []
|
|
877
|
+
_agent_state["response"] = ""
|
|
878
|
+
_agent_state["error"] = None
|
|
879
|
+
_agent_state["interrupt"] = None
|
|
880
|
+
_agent_state["start_time"] = None
|
|
881
|
+
_agent_state["stop_requested"] = False
|
|
882
|
+
_agent_state["last_update"] = time.time()
|
|
883
|
+
# Note: canvas is preserved - it's loaded from canvas.md on startup
|
|
769
884
|
|
|
770
885
|
# =============================================================================
|
|
771
886
|
# DASH APP
|
|
@@ -788,7 +903,7 @@ app.index_string = '''<!DOCTYPE html>
|
|
|
788
903
|
<head>
|
|
789
904
|
{%metas%}
|
|
790
905
|
<title>{%title%}</title>
|
|
791
|
-
<link rel="icon" type="image/svg+xml" href="/assets/favicon.
|
|
906
|
+
<link rel="icon" type="image/svg+xml" href="/assets/favicon.ico">
|
|
792
907
|
{%css%}
|
|
793
908
|
</head>
|
|
794
909
|
<body>
|
|
@@ -818,7 +933,8 @@ def create_layout():
|
|
|
818
933
|
app_subtitle=subtitle,
|
|
819
934
|
colors=COLORS,
|
|
820
935
|
styles=STYLES,
|
|
821
|
-
agent=agent
|
|
936
|
+
agent=agent,
|
|
937
|
+
welcome_message=WELCOME_MESSAGE
|
|
822
938
|
)
|
|
823
939
|
|
|
824
940
|
# Set layout as a function so it uses current WORKSPACE_ROOT
|
|
@@ -833,15 +949,32 @@ app.layout = create_layout
|
|
|
833
949
|
|
|
834
950
|
# Initial message display
|
|
835
951
|
@app.callback(
|
|
836
|
-
Output("chat-messages", "children"),
|
|
952
|
+
[Output("chat-messages", "children"),
|
|
953
|
+
Output("skip-history-render", "data", allow_duplicate=True),
|
|
954
|
+
Output("session-initialized", "data", allow_duplicate=True)],
|
|
837
955
|
[Input("chat-history", "data")],
|
|
838
|
-
[State("theme-store", "data")
|
|
839
|
-
|
|
956
|
+
[State("theme-store", "data"),
|
|
957
|
+
State("skip-history-render", "data"),
|
|
958
|
+
State("session-initialized", "data")],
|
|
959
|
+
prevent_initial_call="initial_duplicate"
|
|
840
960
|
)
|
|
841
|
-
def display_initial_messages(history, theme):
|
|
842
|
-
"""Display initial welcome message or chat history.
|
|
961
|
+
def display_initial_messages(history, theme, skip_render, session_initialized):
|
|
962
|
+
"""Display initial welcome message or chat history.
|
|
963
|
+
|
|
964
|
+
On first call (page load), resets agent state for a fresh session.
|
|
965
|
+
Skip rendering if skip_render flag is set - this prevents duplicate renders
|
|
966
|
+
when poll_agent_updates already handles the rendering.
|
|
967
|
+
"""
|
|
968
|
+
# Reset agent state on page load (first callback trigger)
|
|
969
|
+
if not session_initialized:
|
|
970
|
+
reset_agent_state()
|
|
971
|
+
|
|
972
|
+
# Skip if flag is set (poll_agent_updates already rendered)
|
|
973
|
+
if skip_render:
|
|
974
|
+
return no_update, False, True # Reset skip flag, mark session initialized
|
|
975
|
+
|
|
843
976
|
if not history:
|
|
844
|
-
return []
|
|
977
|
+
return [], False, True
|
|
845
978
|
|
|
846
979
|
colors = get_colors(theme or "light")
|
|
847
980
|
messages = []
|
|
@@ -858,7 +991,7 @@ def display_initial_messages(history, theme):
|
|
|
858
991
|
todos_block = format_todos_inline(msg["todos"], colors)
|
|
859
992
|
if todos_block:
|
|
860
993
|
messages.append(todos_block)
|
|
861
|
-
return messages
|
|
994
|
+
return messages, False, True
|
|
862
995
|
|
|
863
996
|
# Chat callbacks
|
|
864
997
|
@app.callback(
|
|
@@ -871,10 +1004,11 @@ def display_initial_messages(history, theme):
|
|
|
871
1004
|
Input("chat-input", "n_submit")],
|
|
872
1005
|
[State("chat-input", "value"),
|
|
873
1006
|
State("chat-history", "data"),
|
|
874
|
-
State("theme-store", "data")
|
|
1007
|
+
State("theme-store", "data"),
|
|
1008
|
+
State("current-workspace-path", "data")],
|
|
875
1009
|
prevent_initial_call=True
|
|
876
1010
|
)
|
|
877
|
-
def handle_send_immediate(n_clicks, n_submit, message, history, theme):
|
|
1011
|
+
def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_workspace_path):
|
|
878
1012
|
"""Phase 1: Immediately show user message and start agent."""
|
|
879
1013
|
if not message or not message.strip():
|
|
880
1014
|
raise PreventUpdate
|
|
@@ -903,8 +1037,11 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme):
|
|
|
903
1037
|
|
|
904
1038
|
messages.append(format_loading(colors))
|
|
905
1039
|
|
|
906
|
-
#
|
|
907
|
-
|
|
1040
|
+
# Calculate full workspace path for agent context
|
|
1041
|
+
workspace_full_path = WORKSPACE_ROOT / current_workspace_path if current_workspace_path else WORKSPACE_ROOT
|
|
1042
|
+
|
|
1043
|
+
# Start agent in background with workspace context
|
|
1044
|
+
call_agent(message, workspace_path=str(workspace_full_path))
|
|
908
1045
|
|
|
909
1046
|
# Enable polling
|
|
910
1047
|
return messages, history, "", message, False
|
|
@@ -913,7 +1050,8 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme):
|
|
|
913
1050
|
@app.callback(
|
|
914
1051
|
[Output("chat-messages", "children", allow_duplicate=True),
|
|
915
1052
|
Output("chat-history", "data", allow_duplicate=True),
|
|
916
|
-
Output("poll-interval", "disabled", allow_duplicate=True)
|
|
1053
|
+
Output("poll-interval", "disabled", allow_duplicate=True),
|
|
1054
|
+
Output("skip-history-render", "data", allow_duplicate=True)],
|
|
917
1055
|
Input("poll-interval", "n_intervals"),
|
|
918
1056
|
[State("chat-history", "data"),
|
|
919
1057
|
State("pending-message", "data"),
|
|
@@ -977,7 +1115,7 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
977
1115
|
messages.append(interrupt_block)
|
|
978
1116
|
|
|
979
1117
|
# Disable polling - wait for user to respond to interrupt
|
|
980
|
-
return messages, no_update, True
|
|
1118
|
+
return messages, no_update, True, no_update
|
|
981
1119
|
|
|
982
1120
|
# Check if agent is done
|
|
983
1121
|
if not state["running"]:
|
|
@@ -1023,8 +1161,8 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1023
1161
|
if todos_block:
|
|
1024
1162
|
final_messages.append(todos_block)
|
|
1025
1163
|
|
|
1026
|
-
# Disable polling
|
|
1027
|
-
return final_messages, history, True
|
|
1164
|
+
# Disable polling, set skip flag to prevent display_initial_messages from re-rendering
|
|
1165
|
+
return final_messages, history, True, True
|
|
1028
1166
|
else:
|
|
1029
1167
|
# Agent still running - show loading with current thinking/tool_calls/todos
|
|
1030
1168
|
messages = render_history_messages(history)
|
|
@@ -1050,8 +1188,74 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1050
1188
|
# Add loading indicator
|
|
1051
1189
|
messages.append(format_loading(colors))
|
|
1052
1190
|
|
|
1053
|
-
# Continue polling
|
|
1054
|
-
return messages, no_update, False
|
|
1191
|
+
# Continue polling, no skip flag needed
|
|
1192
|
+
return messages, no_update, False, no_update
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
# Stop button visibility - show when agent is running
|
|
1196
|
+
@app.callback(
|
|
1197
|
+
Output("stop-btn", "style"),
|
|
1198
|
+
Input("poll-interval", "n_intervals"),
|
|
1199
|
+
prevent_initial_call=True
|
|
1200
|
+
)
|
|
1201
|
+
def update_stop_button_visibility(n_intervals):
|
|
1202
|
+
"""Show stop button when agent is running, hide otherwise."""
|
|
1203
|
+
state = get_agent_state()
|
|
1204
|
+
if state.get("running"):
|
|
1205
|
+
return {} # Show button (remove display:none)
|
|
1206
|
+
else:
|
|
1207
|
+
return {"display": "none"} # Hide button
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
# Stop button click handler
|
|
1211
|
+
@app.callback(
|
|
1212
|
+
[Output("chat-messages", "children", allow_duplicate=True),
|
|
1213
|
+
Output("poll-interval", "disabled", allow_duplicate=True)],
|
|
1214
|
+
Input("stop-btn", "n_clicks"),
|
|
1215
|
+
[State("chat-history", "data"),
|
|
1216
|
+
State("theme-store", "data")],
|
|
1217
|
+
prevent_initial_call=True
|
|
1218
|
+
)
|
|
1219
|
+
def handle_stop_button(n_clicks, history, theme):
|
|
1220
|
+
"""Handle stop button click to stop agent execution."""
|
|
1221
|
+
if not n_clicks:
|
|
1222
|
+
raise PreventUpdate
|
|
1223
|
+
|
|
1224
|
+
colors = get_colors(theme or "light")
|
|
1225
|
+
history = history or []
|
|
1226
|
+
|
|
1227
|
+
# Request the agent to stop
|
|
1228
|
+
request_agent_stop()
|
|
1229
|
+
|
|
1230
|
+
# Render current messages with a stopping indicator
|
|
1231
|
+
def render_history_messages(history):
|
|
1232
|
+
messages = []
|
|
1233
|
+
for i, msg in enumerate(history):
|
|
1234
|
+
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
1235
|
+
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
|
|
1236
|
+
if msg.get("tool_calls"):
|
|
1237
|
+
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
1238
|
+
if tool_calls_block:
|
|
1239
|
+
messages.append(tool_calls_block)
|
|
1240
|
+
if msg.get("todos"):
|
|
1241
|
+
todos_block = format_todos_inline(msg["todos"], colors)
|
|
1242
|
+
if todos_block:
|
|
1243
|
+
messages.append(todos_block)
|
|
1244
|
+
return messages
|
|
1245
|
+
|
|
1246
|
+
messages = render_history_messages(history)
|
|
1247
|
+
|
|
1248
|
+
# Add stopping message
|
|
1249
|
+
messages.append(html.Div([
|
|
1250
|
+
html.Span("Stopping...", style={
|
|
1251
|
+
"fontSize": "15px",
|
|
1252
|
+
"fontWeight": "500",
|
|
1253
|
+
"color": colors["warning"],
|
|
1254
|
+
})
|
|
1255
|
+
], className="chat-message chat-message-loading", style={"padding": "12px 15px"}))
|
|
1256
|
+
|
|
1257
|
+
# Keep polling to detect when agent actually stops
|
|
1258
|
+
return messages, False
|
|
1055
1259
|
|
|
1056
1260
|
|
|
1057
1261
|
# Interrupt handling callbacks
|
|
@@ -1132,13 +1336,14 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1132
1336
|
return messages, False
|
|
1133
1337
|
|
|
1134
1338
|
|
|
1135
|
-
# Folder toggle callback
|
|
1339
|
+
# Folder toggle callback - triggered by clicking the expand icon
|
|
1136
1340
|
@app.callback(
|
|
1137
1341
|
[Output({"type": "folder-children", "path": ALL}, "style"),
|
|
1138
1342
|
Output({"type": "folder-icon", "path": ALL}, "style"),
|
|
1139
1343
|
Output({"type": "folder-children", "path": ALL}, "children")],
|
|
1140
|
-
Input({"type": "folder-
|
|
1141
|
-
[State({"type": "folder-header", "path": ALL}, "
|
|
1344
|
+
Input({"type": "folder-icon", "path": ALL}, "n_clicks"),
|
|
1345
|
+
[State({"type": "folder-header", "path": ALL}, "id"),
|
|
1346
|
+
State({"type": "folder-header", "path": ALL}, "data-realpath"),
|
|
1142
1347
|
State({"type": "folder-children", "path": ALL}, "id"),
|
|
1143
1348
|
State({"type": "folder-icon", "path": ALL}, "id"),
|
|
1144
1349
|
State({"type": "folder-children", "path": ALL}, "style"),
|
|
@@ -1147,7 +1352,7 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1147
1352
|
State("theme-store", "data")],
|
|
1148
1353
|
prevent_initial_call=True
|
|
1149
1354
|
)
|
|
1150
|
-
def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
|
|
1355
|
+
def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
|
|
1151
1356
|
"""Toggle folder expansion and lazy load contents if needed."""
|
|
1152
1357
|
ctx = callback_context
|
|
1153
1358
|
if not ctx.triggered or not any(n_clicks):
|
|
@@ -1162,17 +1367,13 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1162
1367
|
except:
|
|
1163
1368
|
raise PreventUpdate
|
|
1164
1369
|
|
|
1165
|
-
#
|
|
1166
|
-
|
|
1167
|
-
for i,
|
|
1168
|
-
if
|
|
1169
|
-
|
|
1170
|
-
break
|
|
1370
|
+
# Build a mapping from folder path to real path using header_ids and real_paths
|
|
1371
|
+
path_to_realpath = {}
|
|
1372
|
+
for i, header_id in enumerate(header_ids):
|
|
1373
|
+
if i < len(real_paths):
|
|
1374
|
+
path_to_realpath[header_id["path"]] = real_paths[i]
|
|
1171
1375
|
|
|
1172
|
-
|
|
1173
|
-
raise PreventUpdate
|
|
1174
|
-
|
|
1175
|
-
folder_rel_path = real_paths[clicked_idx] if clicked_idx < len(real_paths) else None
|
|
1376
|
+
folder_rel_path = path_to_realpath.get(clicked_path)
|
|
1176
1377
|
if not folder_rel_path:
|
|
1177
1378
|
raise PreventUpdate
|
|
1178
1379
|
|
|
@@ -1201,7 +1402,7 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1201
1402
|
try:
|
|
1202
1403
|
folder_items = load_folder_contents(folder_rel_path, WORKSPACE_ROOT)
|
|
1203
1404
|
loaded_content = render_file_tree(folder_items, colors, STYLES,
|
|
1204
|
-
level=folder_rel_path.count("/") + 1,
|
|
1405
|
+
level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
|
|
1205
1406
|
parent_path=folder_rel_path)
|
|
1206
1407
|
new_children_content.append(loaded_content if loaded_content else current_content)
|
|
1207
1408
|
except Exception as e:
|
|
@@ -1227,11 +1428,11 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1227
1428
|
current_children_style = children_styles[children_idx] if children_idx < len(children_styles) else {"display": "none"}
|
|
1228
1429
|
is_expanded = current_children_style.get("display") != "none"
|
|
1229
1430
|
new_icon_styles.append({
|
|
1230
|
-
"marginRight": "
|
|
1431
|
+
"marginRight": "5px",
|
|
1231
1432
|
"fontSize": "10px",
|
|
1232
|
-
"
|
|
1233
|
-
"transition": "transform 0.2s",
|
|
1433
|
+
"transition": "transform 0.15s",
|
|
1234
1434
|
"display": "inline-block",
|
|
1435
|
+
"padding": "2px",
|
|
1235
1436
|
"transform": "rotate(0deg)" if is_expanded else "rotate(90deg)",
|
|
1236
1437
|
})
|
|
1237
1438
|
else:
|
|
@@ -1242,6 +1443,129 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1242
1443
|
return new_children_styles, new_icon_styles, new_children_content
|
|
1243
1444
|
|
|
1244
1445
|
|
|
1446
|
+
# Enter folder callback - triggered by double-clicking folder name (changes workspace root)
|
|
1447
|
+
@app.callback(
|
|
1448
|
+
[Output("current-workspace-path", "data"),
|
|
1449
|
+
Output("workspace-breadcrumb", "children"),
|
|
1450
|
+
Output("file-tree", "children", allow_duplicate=True)],
|
|
1451
|
+
[Input({"type": "folder-select", "path": ALL}, "n_clicks"),
|
|
1452
|
+
Input("breadcrumb-root", "n_clicks"),
|
|
1453
|
+
Input({"type": "breadcrumb-segment", "index": ALL}, "n_clicks")],
|
|
1454
|
+
[State({"type": "folder-select", "path": ALL}, "id"),
|
|
1455
|
+
State({"type": "folder-select", "path": ALL}, "data-folderpath"),
|
|
1456
|
+
State({"type": "folder-select", "path": ALL}, "n_clicks"),
|
|
1457
|
+
State("current-workspace-path", "data"),
|
|
1458
|
+
State("theme-store", "data")],
|
|
1459
|
+
prevent_initial_call=True
|
|
1460
|
+
)
|
|
1461
|
+
def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, folder_paths, prev_clicks, current_path, theme):
|
|
1462
|
+
"""Enter a folder (double-click) or navigate via breadcrumb."""
|
|
1463
|
+
ctx = callback_context
|
|
1464
|
+
if not ctx.triggered:
|
|
1465
|
+
raise PreventUpdate
|
|
1466
|
+
|
|
1467
|
+
colors = get_colors(theme or "light")
|
|
1468
|
+
triggered = ctx.triggered[0]["prop_id"]
|
|
1469
|
+
|
|
1470
|
+
new_path = current_path or ""
|
|
1471
|
+
|
|
1472
|
+
# Check if breadcrumb root was clicked
|
|
1473
|
+
if "breadcrumb-root" in triggered:
|
|
1474
|
+
new_path = ""
|
|
1475
|
+
# Check if a breadcrumb segment was clicked
|
|
1476
|
+
elif "breadcrumb-segment" in triggered:
|
|
1477
|
+
try:
|
|
1478
|
+
id_str = triggered.rsplit(".", 1)[0]
|
|
1479
|
+
id_dict = json.loads(id_str)
|
|
1480
|
+
segment_index = id_dict.get("index")
|
|
1481
|
+
# Build path up to this segment
|
|
1482
|
+
if current_path:
|
|
1483
|
+
parts = current_path.split("/")
|
|
1484
|
+
new_path = "/".join(parts[:segment_index + 1])
|
|
1485
|
+
else:
|
|
1486
|
+
new_path = ""
|
|
1487
|
+
except:
|
|
1488
|
+
raise PreventUpdate
|
|
1489
|
+
# Check if a folder was double-clicked (n_clicks >= 2 and increased by 1)
|
|
1490
|
+
elif "folder-select" in triggered:
|
|
1491
|
+
try:
|
|
1492
|
+
id_str = triggered.rsplit(".", 1)[0]
|
|
1493
|
+
id_dict = json.loads(id_str)
|
|
1494
|
+
clicked_path = id_dict.get("path")
|
|
1495
|
+
except:
|
|
1496
|
+
raise PreventUpdate
|
|
1497
|
+
|
|
1498
|
+
# Find the folder and check for double-click
|
|
1499
|
+
for i, folder_id in enumerate(folder_ids):
|
|
1500
|
+
if folder_id["path"] == clicked_path:
|
|
1501
|
+
current_clicks = folder_clicks[i] if i < len(folder_clicks) else 0
|
|
1502
|
+
previous_clicks = prev_clicks[i] if i < len(prev_clicks) else 0
|
|
1503
|
+
|
|
1504
|
+
# Only enter on double-click (clicks increased and is even number >= 2)
|
|
1505
|
+
if current_clicks and current_clicks >= 2 and current_clicks % 2 == 0:
|
|
1506
|
+
folder_rel_path = folder_paths[i] if i < len(folder_paths) else ""
|
|
1507
|
+
# Combine with current workspace path
|
|
1508
|
+
if current_path:
|
|
1509
|
+
new_path = f"{current_path}/{folder_rel_path}"
|
|
1510
|
+
else:
|
|
1511
|
+
new_path = folder_rel_path
|
|
1512
|
+
else:
|
|
1513
|
+
# Single click - don't change workspace
|
|
1514
|
+
raise PreventUpdate
|
|
1515
|
+
break
|
|
1516
|
+
else:
|
|
1517
|
+
raise PreventUpdate
|
|
1518
|
+
else:
|
|
1519
|
+
raise PreventUpdate
|
|
1520
|
+
|
|
1521
|
+
# Build breadcrumb navigation
|
|
1522
|
+
breadcrumb_children = [
|
|
1523
|
+
html.Span([
|
|
1524
|
+
DashIconify(icon="mdi:home", width=14, style={"marginRight": "4px"}),
|
|
1525
|
+
"root"
|
|
1526
|
+
], id="breadcrumb-root", className="breadcrumb-item breadcrumb-clickable", style={
|
|
1527
|
+
"display": "inline-flex",
|
|
1528
|
+
"alignItems": "center",
|
|
1529
|
+
"cursor": "pointer",
|
|
1530
|
+
"padding": "2px 6px",
|
|
1531
|
+
"borderRadius": "3px",
|
|
1532
|
+
}),
|
|
1533
|
+
]
|
|
1534
|
+
|
|
1535
|
+
if new_path:
|
|
1536
|
+
parts = new_path.split("/")
|
|
1537
|
+
for i, part in enumerate(parts):
|
|
1538
|
+
# Add separator
|
|
1539
|
+
breadcrumb_children.append(
|
|
1540
|
+
html.Span(" / ", className="breadcrumb-separator", style={
|
|
1541
|
+
"color": "var(--mantine-color-dimmed)",
|
|
1542
|
+
"margin": "0 2px",
|
|
1543
|
+
})
|
|
1544
|
+
)
|
|
1545
|
+
# Add clickable segment
|
|
1546
|
+
breadcrumb_children.append(
|
|
1547
|
+
html.Span(part, id={"type": "breadcrumb-segment", "index": i},
|
|
1548
|
+
className="breadcrumb-item breadcrumb-clickable",
|
|
1549
|
+
style={
|
|
1550
|
+
"cursor": "pointer",
|
|
1551
|
+
"padding": "2px 6px",
|
|
1552
|
+
"borderRadius": "3px",
|
|
1553
|
+
}
|
|
1554
|
+
)
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
# Calculate the actual workspace path
|
|
1558
|
+
workspace_full_path = WORKSPACE_ROOT / new_path if new_path else WORKSPACE_ROOT
|
|
1559
|
+
|
|
1560
|
+
# Render new file tree
|
|
1561
|
+
file_tree = render_file_tree(
|
|
1562
|
+
build_file_tree(workspace_full_path, workspace_full_path),
|
|
1563
|
+
colors, STYLES
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
return new_path, breadcrumb_children, file_tree
|
|
1567
|
+
|
|
1568
|
+
|
|
1245
1569
|
# File click - open modal
|
|
1246
1570
|
@app.callback(
|
|
1247
1571
|
[Output("file-modal", "opened"),
|
|
@@ -1416,69 +1740,149 @@ def open_terminal(n_clicks):
|
|
|
1416
1740
|
[Output("file-tree", "children"),
|
|
1417
1741
|
Output("canvas-content", "children", allow_duplicate=True)],
|
|
1418
1742
|
Input("refresh-btn", "n_clicks"),
|
|
1419
|
-
[State("
|
|
1743
|
+
[State("current-workspace-path", "data"),
|
|
1744
|
+
State("theme-store", "data"),
|
|
1745
|
+
State("collapsed-canvas-items", "data")],
|
|
1420
1746
|
prevent_initial_call=True
|
|
1421
1747
|
)
|
|
1422
|
-
def refresh_sidebar(n_clicks, theme):
|
|
1748
|
+
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
|
|
1423
1749
|
"""Refresh both file tree and canvas content."""
|
|
1424
1750
|
global _agent_state
|
|
1425
1751
|
colors = get_colors(theme or "light")
|
|
1752
|
+
collapsed_ids = collapsed_ids or []
|
|
1426
1753
|
|
|
1427
|
-
#
|
|
1428
|
-
|
|
1754
|
+
# Calculate current workspace directory
|
|
1755
|
+
current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
|
|
1429
1756
|
|
|
1430
|
-
# Refresh
|
|
1757
|
+
# Refresh file tree for current workspace
|
|
1758
|
+
file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
1759
|
+
|
|
1760
|
+
# Refresh canvas by reloading from .canvas/canvas.md file (always from original root)
|
|
1431
1761
|
canvas_items = load_canvas_from_markdown(WORKSPACE_ROOT)
|
|
1432
1762
|
|
|
1433
1763
|
# Update agent state with reloaded canvas
|
|
1434
1764
|
with _agent_state_lock:
|
|
1435
1765
|
_agent_state["canvas"] = canvas_items
|
|
1436
1766
|
|
|
1437
|
-
# Render the canvas items
|
|
1438
|
-
canvas_content = render_canvas_items(canvas_items, colors)
|
|
1767
|
+
# Render the canvas items with preserved collapsed state
|
|
1768
|
+
canvas_content = render_canvas_items(canvas_items, colors, collapsed_ids)
|
|
1439
1769
|
|
|
1440
1770
|
return file_tree, canvas_content
|
|
1441
1771
|
|
|
1442
1772
|
|
|
1443
|
-
# File upload
|
|
1773
|
+
# File upload (sidebar button) - uploads to current workspace directory
|
|
1444
1774
|
@app.callback(
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1775
|
+
Output("file-tree", "children", allow_duplicate=True),
|
|
1776
|
+
Input("file-upload-sidebar", "contents"),
|
|
1777
|
+
[State("file-upload-sidebar", "filename"),
|
|
1778
|
+
State("current-workspace-path", "data"),
|
|
1449
1779
|
State("theme-store", "data")],
|
|
1450
1780
|
prevent_initial_call=True
|
|
1451
1781
|
)
|
|
1452
|
-
def
|
|
1453
|
-
"""Handle file uploads."""
|
|
1782
|
+
def handle_sidebar_upload(contents, filenames, current_workspace, theme):
|
|
1783
|
+
"""Handle file uploads from sidebar button to current workspace."""
|
|
1454
1784
|
if not contents:
|
|
1455
1785
|
raise PreventUpdate
|
|
1456
1786
|
|
|
1457
1787
|
colors = get_colors(theme or "light")
|
|
1458
|
-
|
|
1788
|
+
# Calculate current workspace directory
|
|
1789
|
+
current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
|
|
1790
|
+
|
|
1459
1791
|
for content, filename in zip(contents, filenames):
|
|
1460
1792
|
try:
|
|
1461
1793
|
_, content_string = content.split(',')
|
|
1462
1794
|
decoded = base64.b64decode(content_string)
|
|
1463
|
-
file_path =
|
|
1795
|
+
file_path = current_workspace_dir / filename
|
|
1464
1796
|
try:
|
|
1465
1797
|
file_path.write_text(decoded.decode('utf-8'))
|
|
1466
1798
|
except UnicodeDecodeError:
|
|
1467
1799
|
file_path.write_bytes(decoded)
|
|
1468
|
-
uploaded.append(filename)
|
|
1469
1800
|
except Exception as e:
|
|
1470
1801
|
print(f"Upload error: {e}")
|
|
1471
1802
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1803
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
# Create folder modal - open
|
|
1807
|
+
@app.callback(
|
|
1808
|
+
Output("create-folder-modal", "opened"),
|
|
1809
|
+
[Input("create-folder-btn", "n_clicks"),
|
|
1810
|
+
Input("cancel-folder-btn", "n_clicks"),
|
|
1811
|
+
Input("confirm-folder-btn", "n_clicks")],
|
|
1812
|
+
[State("create-folder-modal", "opened"),
|
|
1813
|
+
State("new-folder-name", "value")],
|
|
1814
|
+
prevent_initial_call=True
|
|
1815
|
+
)
|
|
1816
|
+
def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_open, folder_name):
|
|
1817
|
+
"""Open/close the create folder modal."""
|
|
1818
|
+
ctx = callback_context
|
|
1819
|
+
if not ctx.triggered:
|
|
1820
|
+
raise PreventUpdate
|
|
1821
|
+
|
|
1822
|
+
trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
1823
|
+
|
|
1824
|
+
if trigger_id == "create-folder-btn":
|
|
1825
|
+
return True
|
|
1826
|
+
elif trigger_id == "cancel-folder-btn":
|
|
1827
|
+
return False
|
|
1828
|
+
elif trigger_id == "confirm-folder-btn":
|
|
1829
|
+
# Close modal only if folder name is provided
|
|
1830
|
+
if folder_name and folder_name.strip():
|
|
1831
|
+
return False
|
|
1832
|
+
return True # Keep open if no name provided
|
|
1833
|
+
|
|
1834
|
+
return is_open
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
# Create folder - action
|
|
1838
|
+
@app.callback(
|
|
1839
|
+
[Output("file-tree", "children", allow_duplicate=True),
|
|
1840
|
+
Output("create-folder-error", "children"),
|
|
1841
|
+
Output("new-folder-name", "value")],
|
|
1842
|
+
Input("confirm-folder-btn", "n_clicks"),
|
|
1843
|
+
[State("new-folder-name", "value"),
|
|
1844
|
+
State("current-workspace-path", "data"),
|
|
1845
|
+
State("theme-store", "data")],
|
|
1846
|
+
prevent_initial_call=True
|
|
1847
|
+
)
|
|
1848
|
+
def create_folder(n_clicks, folder_name, current_workspace, theme):
|
|
1849
|
+
"""Create a new folder in the current workspace directory."""
|
|
1850
|
+
if not n_clicks:
|
|
1851
|
+
raise PreventUpdate
|
|
1852
|
+
|
|
1853
|
+
colors = get_colors(theme or "light")
|
|
1854
|
+
|
|
1855
|
+
if not folder_name or not folder_name.strip():
|
|
1856
|
+
return no_update, "Please enter a folder name", no_update
|
|
1857
|
+
|
|
1858
|
+
folder_name = folder_name.strip()
|
|
1859
|
+
|
|
1860
|
+
# Validate folder name
|
|
1861
|
+
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
|
1862
|
+
if any(char in folder_name for char in invalid_chars):
|
|
1863
|
+
return no_update, f"Folder name cannot contain: {' '.join(invalid_chars)}", no_update
|
|
1864
|
+
|
|
1865
|
+
# Calculate current workspace directory
|
|
1866
|
+
current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
|
|
1867
|
+
folder_path = current_workspace_dir / folder_name
|
|
1868
|
+
|
|
1869
|
+
if folder_path.exists():
|
|
1870
|
+
return no_update, f"Folder '{folder_name}' already exists", no_update
|
|
1871
|
+
|
|
1872
|
+
try:
|
|
1873
|
+
folder_path.mkdir(parents=True, exist_ok=False)
|
|
1874
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES), "", ""
|
|
1875
|
+
except Exception as e:
|
|
1876
|
+
return no_update, f"Error creating folder: {e}", no_update
|
|
1475
1877
|
|
|
1476
1878
|
|
|
1477
1879
|
# View toggle callbacks - using SegmentedControl
|
|
1478
1880
|
@app.callback(
|
|
1479
1881
|
[Output("files-view", "style"),
|
|
1480
1882
|
Output("canvas-view", "style"),
|
|
1481
|
-
Output("open-terminal-btn", "style")
|
|
1883
|
+
Output("open-terminal-btn", "style"),
|
|
1884
|
+
Output("create-folder-btn", "style"),
|
|
1885
|
+
Output("file-upload-sidebar", "style")],
|
|
1482
1886
|
[Input("sidebar-view-toggle", "value")],
|
|
1483
1887
|
prevent_initial_call=True
|
|
1484
1888
|
)
|
|
@@ -1488,7 +1892,7 @@ def toggle_view(view_value):
|
|
|
1488
1892
|
raise PreventUpdate
|
|
1489
1893
|
|
|
1490
1894
|
if view_value == "canvas":
|
|
1491
|
-
# Show canvas, hide files, hide
|
|
1895
|
+
# Show canvas, hide files, hide file-related buttons
|
|
1492
1896
|
return (
|
|
1493
1897
|
{"flex": "1", "display": "none", "flexDirection": "column"},
|
|
1494
1898
|
{
|
|
@@ -1498,10 +1902,12 @@ def toggle_view(view_value):
|
|
|
1498
1902
|
"flexDirection": "column",
|
|
1499
1903
|
"overflow": "hidden"
|
|
1500
1904
|
},
|
|
1501
|
-
{"display": "none"} # Hide terminal button
|
|
1905
|
+
{"display": "none"}, # Hide terminal button
|
|
1906
|
+
{"display": "none"}, # Hide create folder button
|
|
1907
|
+
{"display": "none"}, # Hide file upload button
|
|
1502
1908
|
)
|
|
1503
1909
|
else:
|
|
1504
|
-
# Show files, hide canvas, show
|
|
1910
|
+
# Show files, hide canvas, show file-related buttons
|
|
1505
1911
|
return (
|
|
1506
1912
|
{
|
|
1507
1913
|
"flex": "1",
|
|
@@ -1517,7 +1923,9 @@ def toggle_view(view_value):
|
|
|
1517
1923
|
"flexDirection": "column",
|
|
1518
1924
|
"overflow": "hidden"
|
|
1519
1925
|
},
|
|
1520
|
-
{} # Show terminal button (default styles)
|
|
1926
|
+
{}, # Show terminal button (default styles)
|
|
1927
|
+
{}, # Show create folder button (default styles)
|
|
1928
|
+
{}, # Show file upload button (default styles)
|
|
1521
1929
|
)
|
|
1522
1930
|
|
|
1523
1931
|
|
|
@@ -1526,78 +1934,274 @@ def toggle_view(view_value):
|
|
|
1526
1934
|
Output("canvas-content", "children"),
|
|
1527
1935
|
[Input("poll-interval", "n_intervals"),
|
|
1528
1936
|
Input("sidebar-view-toggle", "value")],
|
|
1529
|
-
[State("theme-store", "data")
|
|
1937
|
+
[State("theme-store", "data"),
|
|
1938
|
+
State("collapsed-canvas-items", "data")],
|
|
1530
1939
|
prevent_initial_call=False
|
|
1531
1940
|
)
|
|
1532
|
-
def update_canvas_content(n_intervals, view_value, theme):
|
|
1941
|
+
def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
|
|
1533
1942
|
"""Update canvas content from agent state."""
|
|
1534
1943
|
state = get_agent_state()
|
|
1535
1944
|
canvas_items = state.get("canvas", [])
|
|
1536
1945
|
colors = get_colors(theme or "light")
|
|
1946
|
+
collapsed_ids = collapsed_ids or []
|
|
1537
1947
|
|
|
1538
|
-
# Use imported rendering function
|
|
1539
|
-
return render_canvas_items(canvas_items, colors)
|
|
1948
|
+
# Use imported rendering function with preserved collapsed state
|
|
1949
|
+
return render_canvas_items(canvas_items, colors, collapsed_ids)
|
|
1540
1950
|
|
|
1541
1951
|
|
|
1542
1952
|
|
|
1543
|
-
#
|
|
1953
|
+
# Open clear canvas confirmation modal
|
|
1544
1954
|
@app.callback(
|
|
1545
|
-
Output("canvas-
|
|
1955
|
+
Output("clear-canvas-modal", "opened"),
|
|
1546
1956
|
Input("clear-canvas-btn", "n_clicks"),
|
|
1547
|
-
[State("theme-store", "data")],
|
|
1548
1957
|
prevent_initial_call=True
|
|
1549
1958
|
)
|
|
1550
|
-
def
|
|
1551
|
-
"""
|
|
1959
|
+
def open_clear_canvas_modal(n_clicks):
|
|
1960
|
+
"""Open the clear canvas confirmation modal."""
|
|
1552
1961
|
if not n_clicks:
|
|
1553
1962
|
raise PreventUpdate
|
|
1963
|
+
return True
|
|
1554
1964
|
|
|
1555
|
-
global _agent_state
|
|
1556
|
-
colors = get_colors(theme or "light")
|
|
1557
1965
|
|
|
1558
|
-
|
|
1966
|
+
# Handle clear canvas confirmation
|
|
1967
|
+
@app.callback(
|
|
1968
|
+
[Output("canvas-content", "children", allow_duplicate=True),
|
|
1969
|
+
Output("clear-canvas-modal", "opened", allow_duplicate=True),
|
|
1970
|
+
Output("collapsed-canvas-items", "data", allow_duplicate=True)],
|
|
1971
|
+
[Input("confirm-clear-canvas-btn", "n_clicks"),
|
|
1972
|
+
Input("cancel-clear-canvas-btn", "n_clicks")],
|
|
1973
|
+
[State("theme-store", "data")],
|
|
1974
|
+
prevent_initial_call=True
|
|
1975
|
+
)
|
|
1976
|
+
def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
|
|
1977
|
+
"""Handle the clear canvas confirmation - either clear or cancel."""
|
|
1978
|
+
ctx = callback_context
|
|
1979
|
+
if not ctx.triggered:
|
|
1980
|
+
raise PreventUpdate
|
|
1559
1981
|
|
|
1560
|
-
|
|
1561
|
-
canvas_dir = WORKSPACE_ROOT / ".canvas"
|
|
1562
|
-
if canvas_dir.exists() and canvas_dir.is_dir():
|
|
1563
|
-
try:
|
|
1564
|
-
archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
|
|
1565
|
-
shutil.move(str(canvas_dir), str(archive_dir))
|
|
1566
|
-
print(f"Archived .canvas folder to {archive_dir}")
|
|
1567
|
-
except Exception as e:
|
|
1568
|
-
print(f"Failed to archive .canvas folder: {e}")
|
|
1982
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
1569
1983
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1984
|
+
if triggered_id == "cancel-clear-canvas-btn":
|
|
1985
|
+
# Close modal without clearing
|
|
1986
|
+
return no_update, False, no_update
|
|
1987
|
+
|
|
1988
|
+
if triggered_id == "confirm-clear-canvas-btn":
|
|
1989
|
+
if not confirm_clicks:
|
|
1990
|
+
raise PreventUpdate
|
|
1991
|
+
|
|
1992
|
+
global _agent_state
|
|
1993
|
+
colors = get_colors(theme or "light")
|
|
1994
|
+
|
|
1995
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1996
|
+
|
|
1997
|
+
# Archive .canvas folder if it exists (contains canvas.md and all assets)
|
|
1998
|
+
canvas_dir = WORKSPACE_ROOT / ".canvas"
|
|
1999
|
+
if canvas_dir.exists() and canvas_dir.is_dir():
|
|
2000
|
+
try:
|
|
2001
|
+
archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
|
|
2002
|
+
shutil.move(str(canvas_dir), str(archive_dir))
|
|
2003
|
+
print(f"Archived .canvas folder to {archive_dir}")
|
|
2004
|
+
except Exception as e:
|
|
2005
|
+
print(f"Failed to archive .canvas folder: {e}")
|
|
2006
|
+
|
|
2007
|
+
# Clear canvas in state
|
|
2008
|
+
with _agent_state_lock:
|
|
2009
|
+
_agent_state["canvas"] = []
|
|
2010
|
+
|
|
2011
|
+
# Return empty state, close modal, and clear collapsed items
|
|
2012
|
+
return html.Div([
|
|
2013
|
+
html.Div("🗒", style={
|
|
2014
|
+
"fontSize": "48px",
|
|
2015
|
+
"textAlign": "center",
|
|
2016
|
+
"marginBottom": "16px",
|
|
2017
|
+
"opacity": "0.3"
|
|
2018
|
+
}),
|
|
2019
|
+
html.P("Canvas is empty", style={
|
|
2020
|
+
"textAlign": "center",
|
|
2021
|
+
"color": colors["text_muted"],
|
|
2022
|
+
"fontSize": "14px"
|
|
2023
|
+
}),
|
|
2024
|
+
html.P("The agent will add visualizations, charts, and notes here", style={
|
|
2025
|
+
"textAlign": "center",
|
|
2026
|
+
"color": colors["text_muted"],
|
|
2027
|
+
"fontSize": "12px",
|
|
2028
|
+
"marginTop": "8px"
|
|
2029
|
+
})
|
|
2030
|
+
], style={
|
|
2031
|
+
"display": "flex",
|
|
2032
|
+
"flexDirection": "column",
|
|
2033
|
+
"alignItems": "center",
|
|
2034
|
+
"justifyContent": "center",
|
|
2035
|
+
"height": "100%",
|
|
2036
|
+
"padding": "40px"
|
|
2037
|
+
}), False, []
|
|
2038
|
+
|
|
2039
|
+
raise PreventUpdate
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
# Collapse/expand canvas item callback
|
|
2043
|
+
@app.callback(
|
|
2044
|
+
[Output({"type": "canvas-item-content", "index": ALL}, "style"),
|
|
2045
|
+
Output({"type": "canvas-collapse-btn", "index": ALL}, "children"),
|
|
2046
|
+
Output("collapsed-canvas-items", "data")],
|
|
2047
|
+
Input({"type": "canvas-collapse-btn", "index": ALL}, "n_clicks"),
|
|
2048
|
+
[State({"type": "canvas-collapse-btn", "index": ALL}, "id"),
|
|
2049
|
+
State({"type": "canvas-item-content", "index": ALL}, "style"),
|
|
2050
|
+
State({"type": "canvas-item-content", "index": ALL}, "id"),
|
|
2051
|
+
State("collapsed-canvas-items", "data")],
|
|
2052
|
+
prevent_initial_call=True
|
|
2053
|
+
)
|
|
2054
|
+
def toggle_canvas_item_collapse(all_clicks, btn_ids, content_styles, content_ids, collapsed_ids):
|
|
2055
|
+
"""Toggle collapse/expand state of a canvas item."""
|
|
2056
|
+
ctx = callback_context
|
|
2057
|
+
if not ctx.triggered:
|
|
2058
|
+
raise PreventUpdate
|
|
2059
|
+
|
|
2060
|
+
# Find which button was clicked
|
|
2061
|
+
triggered = ctx.triggered[0]
|
|
2062
|
+
triggered_id = triggered["prop_id"]
|
|
2063
|
+
triggered_value = triggered.get("value")
|
|
2064
|
+
|
|
2065
|
+
if not triggered_value or triggered_value <= 0:
|
|
2066
|
+
raise PreventUpdate
|
|
2067
|
+
|
|
2068
|
+
try:
|
|
2069
|
+
id_str = triggered_id.rsplit(".", 1)[0]
|
|
2070
|
+
id_dict = json.loads(id_str)
|
|
2071
|
+
clicked_item_id = id_dict.get("index")
|
|
2072
|
+
except:
|
|
2073
|
+
raise PreventUpdate
|
|
2074
|
+
|
|
2075
|
+
if not clicked_item_id:
|
|
2076
|
+
raise PreventUpdate
|
|
2077
|
+
|
|
2078
|
+
# Initialize collapsed_ids if None
|
|
2079
|
+
collapsed_ids = collapsed_ids or []
|
|
2080
|
+
|
|
2081
|
+
# Build new styles and icons for all items
|
|
2082
|
+
new_styles = []
|
|
2083
|
+
new_icons = []
|
|
2084
|
+
new_collapsed_ids = collapsed_ids.copy()
|
|
2085
|
+
|
|
2086
|
+
for i, content_id in enumerate(content_ids):
|
|
2087
|
+
item_id = content_id.get("index")
|
|
2088
|
+
current_style = content_styles[i] if i < len(content_styles) else {"display": "block"}
|
|
2089
|
+
|
|
2090
|
+
if item_id == clicked_item_id:
|
|
2091
|
+
# Toggle this item
|
|
2092
|
+
is_collapsed = current_style.get("display") == "none"
|
|
2093
|
+
new_styles.append({"display": "block"} if is_collapsed else {"display": "none"})
|
|
2094
|
+
# Change icon based on new state
|
|
2095
|
+
new_icons.append(
|
|
2096
|
+
DashIconify(icon="mdi:chevron-down" if is_collapsed else "mdi:chevron-right", width=16)
|
|
2097
|
+
)
|
|
2098
|
+
# Update collapsed_ids list
|
|
2099
|
+
if is_collapsed:
|
|
2100
|
+
# Was collapsed, now expanding - remove from list
|
|
2101
|
+
if item_id in new_collapsed_ids:
|
|
2102
|
+
new_collapsed_ids.remove(item_id)
|
|
2103
|
+
else:
|
|
2104
|
+
# Was expanded, now collapsing - add to list
|
|
2105
|
+
if item_id not in new_collapsed_ids:
|
|
2106
|
+
new_collapsed_ids.append(item_id)
|
|
2107
|
+
else:
|
|
2108
|
+
new_styles.append(current_style)
|
|
2109
|
+
# Keep existing icon state
|
|
2110
|
+
is_collapsed = current_style.get("display") == "none"
|
|
2111
|
+
new_icons.append(
|
|
2112
|
+
DashIconify(icon="mdi:chevron-right" if is_collapsed else "mdi:chevron-down", width=16)
|
|
2113
|
+
)
|
|
2114
|
+
|
|
2115
|
+
return new_styles, new_icons, new_collapsed_ids
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
# Open delete confirmation modal
|
|
2119
|
+
@app.callback(
|
|
2120
|
+
[Output("delete-canvas-item-modal", "opened"),
|
|
2121
|
+
Output("delete-canvas-item-id", "data")],
|
|
2122
|
+
Input({"type": "canvas-delete-btn", "index": ALL}, "n_clicks"),
|
|
2123
|
+
[State({"type": "canvas-delete-btn", "index": ALL}, "id")],
|
|
2124
|
+
prevent_initial_call=True
|
|
2125
|
+
)
|
|
2126
|
+
def open_delete_confirmation(all_clicks, all_ids):
|
|
2127
|
+
"""Open the delete confirmation modal when delete button is clicked."""
|
|
2128
|
+
ctx = callback_context
|
|
2129
|
+
if not ctx.triggered:
|
|
2130
|
+
raise PreventUpdate
|
|
2131
|
+
|
|
2132
|
+
triggered = ctx.triggered[0]
|
|
2133
|
+
triggered_id = triggered["prop_id"]
|
|
2134
|
+
triggered_value = triggered.get("value")
|
|
2135
|
+
|
|
2136
|
+
if not triggered_value or triggered_value <= 0:
|
|
2137
|
+
raise PreventUpdate
|
|
2138
|
+
|
|
2139
|
+
try:
|
|
2140
|
+
id_str = triggered_id.rsplit(".", 1)[0]
|
|
2141
|
+
id_dict = json.loads(id_str)
|
|
2142
|
+
item_id_to_delete = id_dict.get("index")
|
|
2143
|
+
except:
|
|
2144
|
+
raise PreventUpdate
|
|
2145
|
+
|
|
2146
|
+
if not item_id_to_delete:
|
|
2147
|
+
raise PreventUpdate
|
|
2148
|
+
|
|
2149
|
+
return True, item_id_to_delete
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
# Handle delete confirmation modal actions
|
|
2153
|
+
@app.callback(
|
|
2154
|
+
[Output("canvas-content", "children", allow_duplicate=True),
|
|
2155
|
+
Output("delete-canvas-item-modal", "opened", allow_duplicate=True),
|
|
2156
|
+
Output("collapsed-canvas-items", "data", allow_duplicate=True)],
|
|
2157
|
+
[Input("confirm-delete-canvas-btn", "n_clicks"),
|
|
2158
|
+
Input("cancel-delete-canvas-btn", "n_clicks")],
|
|
2159
|
+
[State("delete-canvas-item-id", "data"),
|
|
2160
|
+
State("theme-store", "data"),
|
|
2161
|
+
State("collapsed-canvas-items", "data")],
|
|
2162
|
+
prevent_initial_call=True
|
|
2163
|
+
)
|
|
2164
|
+
def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, collapsed_ids):
|
|
2165
|
+
"""Handle the delete confirmation - either delete or cancel."""
|
|
2166
|
+
ctx = callback_context
|
|
2167
|
+
if not ctx.triggered:
|
|
2168
|
+
raise PreventUpdate
|
|
2169
|
+
|
|
2170
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
2171
|
+
|
|
2172
|
+
if triggered_id == "cancel-delete-canvas-btn":
|
|
2173
|
+
# Close modal without deleting
|
|
2174
|
+
return no_update, False, no_update
|
|
2175
|
+
|
|
2176
|
+
if triggered_id == "confirm-delete-canvas-btn":
|
|
2177
|
+
if not confirm_clicks or not item_id:
|
|
2178
|
+
raise PreventUpdate
|
|
2179
|
+
|
|
2180
|
+
global _agent_state
|
|
2181
|
+
colors = get_colors(theme or "light")
|
|
2182
|
+
collapsed_ids = collapsed_ids or []
|
|
2183
|
+
|
|
2184
|
+
# Remove the item from canvas
|
|
2185
|
+
with _agent_state_lock:
|
|
2186
|
+
_agent_state["canvas"] = [
|
|
2187
|
+
item for item in _agent_state["canvas"]
|
|
2188
|
+
if item.get("id") != item_id
|
|
2189
|
+
]
|
|
2190
|
+
canvas_items = _agent_state["canvas"].copy()
|
|
2191
|
+
|
|
2192
|
+
# Export updated canvas to markdown file
|
|
2193
|
+
try:
|
|
2194
|
+
export_canvas_to_markdown(canvas_items, WORKSPACE_ROOT)
|
|
2195
|
+
except Exception as e:
|
|
2196
|
+
print(f"Failed to export canvas after delete: {e}")
|
|
2197
|
+
|
|
2198
|
+
# Remove deleted item from collapsed_ids if present
|
|
2199
|
+
new_collapsed_ids = [cid for cid in collapsed_ids if cid != item_id]
|
|
2200
|
+
|
|
2201
|
+
# Render updated canvas with preserved collapsed state and close modal
|
|
2202
|
+
return render_canvas_items(canvas_items, colors, new_collapsed_ids), False, new_collapsed_ids
|
|
2203
|
+
|
|
2204
|
+
raise PreventUpdate
|
|
1601
2205
|
|
|
1602
2206
|
|
|
1603
2207
|
# =============================================================================
|
|
@@ -1662,6 +2266,7 @@ def run_app(
|
|
|
1662
2266
|
debug=None,
|
|
1663
2267
|
title=None,
|
|
1664
2268
|
subtitle=None,
|
|
2269
|
+
welcome_message=None,
|
|
1665
2270
|
config_file=None
|
|
1666
2271
|
):
|
|
1667
2272
|
"""
|
|
@@ -1682,6 +2287,7 @@ def run_app(
|
|
|
1682
2287
|
debug (bool, optional): Debug mode
|
|
1683
2288
|
title (str, optional): Application title
|
|
1684
2289
|
subtitle (str, optional): Application subtitle
|
|
2290
|
+
welcome_message (str, optional): Welcome message shown on startup (supports markdown)
|
|
1685
2291
|
config_file (str, optional): Path to config file (default: ./config.py)
|
|
1686
2292
|
|
|
1687
2293
|
Returns:
|
|
@@ -1702,7 +2308,7 @@ def run_app(
|
|
|
1702
2308
|
>>> # Without agent (manual mode)
|
|
1703
2309
|
>>> run_app(workspace="~/my-workspace", debug=True)
|
|
1704
2310
|
"""
|
|
1705
|
-
global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, agent, AGENT_ERROR, args
|
|
2311
|
+
global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, WELCOME_MESSAGE, agent, AGENT_ERROR, args
|
|
1706
2312
|
|
|
1707
2313
|
# Load config file if specified and exists
|
|
1708
2314
|
config_module = None
|
|
@@ -1727,6 +2333,7 @@ def run_app(
|
|
|
1727
2333
|
PORT = port if port is not None else getattr(config_module, "PORT", config.PORT)
|
|
1728
2334
|
HOST = host if host else getattr(config_module, "HOST", config.HOST)
|
|
1729
2335
|
DEBUG = debug if debug is not None else getattr(config_module, "DEBUG", config.DEBUG)
|
|
2336
|
+
WELCOME_MESSAGE = welcome_message if welcome_message else getattr(config_module, "WELCOME_MESSAGE", config.WELCOME_MESSAGE)
|
|
1730
2337
|
|
|
1731
2338
|
# Agent priority: agent_spec > agent_instance > config file
|
|
1732
2339
|
if agent_spec:
|
|
@@ -1757,6 +2364,7 @@ def run_app(
|
|
|
1757
2364
|
PORT = port if port is not None else config.PORT
|
|
1758
2365
|
HOST = host if host else config.HOST
|
|
1759
2366
|
DEBUG = debug if debug is not None else config.DEBUG
|
|
2367
|
+
WELCOME_MESSAGE = welcome_message if welcome_message else config.WELCOME_MESSAGE
|
|
1760
2368
|
|
|
1761
2369
|
# Agent priority: agent_spec > agent_instance > config default
|
|
1762
2370
|
if agent_spec:
|