cowork-dash 0.1.3__py3-none-any.whl → 0.1.5__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 +5 -4
- cowork_dash/app.py +711 -137
- 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 +16 -3
- cowork_dash/file_utils.py +17 -6
- cowork_dash/layout.py +115 -23
- cowork_dash/tools.py +88 -11
- {cowork_dash-0.1.3.dist-info → cowork_dash-0.1.5.dist-info}/METADATA +31 -40
- cowork_dash-0.1.5.dist-info/RECORD +20 -0
- cowork_dash-0.1.3.dist-info/RECORD +0 -19
- {cowork_dash-0.1.3.dist-info → cowork_dash-0.1.5.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.3.dist-info → cowork_dash-0.1.5.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.3.dist-info → cowork_dash-0.1.5.dist-info}/licenses/LICENSE +0 -0
cowork_dash/app.py
CHANGED
|
@@ -124,37 +124,38 @@ def load_agent_from_spec(agent_spec: str):
|
|
|
124
124
|
"""
|
|
125
125
|
Load agent from specification string.
|
|
126
126
|
|
|
127
|
-
Supports two formats:
|
|
127
|
+
Supports two formats (both use colon separator):
|
|
128
128
|
1. File path format: "path/to/file.py:object_name"
|
|
129
|
-
2. Module format: "mypackage.module.submodule
|
|
129
|
+
2. Module format: "mypackage.module.submodule:object_name"
|
|
130
130
|
|
|
131
131
|
Args:
|
|
132
132
|
agent_spec: String like "agent.py:agent", "my_agents.py:custom_agent",
|
|
133
|
-
or "mypackage.agents
|
|
133
|
+
or "mypackage.agents:my_agent"
|
|
134
134
|
|
|
135
135
|
Returns:
|
|
136
136
|
tuple: (agent_object, error_message)
|
|
137
137
|
"""
|
|
138
138
|
try:
|
|
139
|
-
#
|
|
140
|
-
if ":" in agent_spec:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
# Both formats use colon separator
|
|
140
|
+
if ":" not in agent_spec:
|
|
141
|
+
return None, f"Invalid agent spec '{agent_spec}'. Expected format: 'path/to/file.py:object' or 'module.path:object'"
|
|
142
|
+
|
|
143
|
+
left_part, object_name = agent_spec.rsplit(":", 1)
|
|
144
|
+
|
|
145
|
+
# Determine if it's a file path or module path
|
|
146
|
+
# File paths end with .py or contain path separators
|
|
147
|
+
if left_part.endswith(".py") or "/" in left_part or "\\" in left_part:
|
|
148
|
+
return _load_agent_from_file(left_part, object_name)
|
|
146
149
|
else:
|
|
147
|
-
|
|
148
|
-
return _load_agent_from_module(agent_spec)
|
|
150
|
+
return _load_agent_from_module(left_part, object_name)
|
|
149
151
|
|
|
150
152
|
except Exception as e:
|
|
151
153
|
return None, f"Failed to load agent from {agent_spec}: {e}"
|
|
152
154
|
|
|
153
155
|
|
|
154
|
-
def _load_agent_from_file(
|
|
156
|
+
def _load_agent_from_file(file_path_str: str, object_name: str):
|
|
155
157
|
"""Load agent from file path format: 'path/to/file.py:object_name'"""
|
|
156
|
-
file_path
|
|
157
|
-
file_path = Path(file_path).resolve()
|
|
158
|
+
file_path = Path(file_path_str).resolve()
|
|
158
159
|
|
|
159
160
|
if not file_path.exists():
|
|
160
161
|
return None, f"Agent file not found: {file_path}"
|
|
@@ -176,15 +177,8 @@ def _load_agent_from_file(agent_spec: str):
|
|
|
176
177
|
return agent, None
|
|
177
178
|
|
|
178
179
|
|
|
179
|
-
def _load_agent_from_module(
|
|
180
|
-
"""Load agent from module format: 'mypackage.module
|
|
181
|
-
parts = agent_spec.rsplit(".", 1)
|
|
182
|
-
|
|
183
|
-
if len(parts) < 2:
|
|
184
|
-
return None, f"Invalid module spec '{agent_spec}'. Expected format: 'module.object_name' or 'package.module.object_name'"
|
|
185
|
-
|
|
186
|
-
module_path, object_name = parts
|
|
187
|
-
|
|
180
|
+
def _load_agent_from_module(module_path: str, object_name: str):
|
|
181
|
+
"""Load agent from module format: 'mypackage.module:object_name'"""
|
|
188
182
|
try:
|
|
189
183
|
# Import the module
|
|
190
184
|
module = importlib.import_module(module_path)
|
|
@@ -207,6 +201,7 @@ APP_SUBTITLE = config.APP_SUBTITLE
|
|
|
207
201
|
PORT = config.PORT
|
|
208
202
|
HOST = config.HOST
|
|
209
203
|
DEBUG = config.DEBUG
|
|
204
|
+
WELCOME_MESSAGE = config.WELCOME_MESSAGE
|
|
210
205
|
|
|
211
206
|
# Ensure workspace exists
|
|
212
207
|
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
@@ -294,15 +289,24 @@ _agent_state = {
|
|
|
294
289
|
"interrupt": None, # Track interrupt requests for human-in-the-loop
|
|
295
290
|
"last_update": time.time(),
|
|
296
291
|
"start_time": None, # Track when agent started for response time calculation
|
|
292
|
+
"stop_requested": False, # Flag to request agent stop
|
|
297
293
|
}
|
|
298
294
|
_agent_state_lock = threading.Lock()
|
|
299
295
|
|
|
300
|
-
|
|
296
|
+
|
|
297
|
+
def request_agent_stop():
|
|
298
|
+
"""Request the agent to stop execution."""
|
|
299
|
+
with _agent_state_lock:
|
|
300
|
+
_agent_state["stop_requested"] = True
|
|
301
|
+
_agent_state["last_update"] = time.time()
|
|
302
|
+
|
|
303
|
+
def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None):
|
|
301
304
|
"""Run agent in background thread and update global state in real-time.
|
|
302
305
|
|
|
303
306
|
Args:
|
|
304
307
|
message: User message to send to agent
|
|
305
308
|
resume_data: Optional dict with 'decisions' to resume from interrupt
|
|
309
|
+
workspace_path: Current workspace directory path to inject into agent context
|
|
306
310
|
"""
|
|
307
311
|
if not agent:
|
|
308
312
|
with _agent_state_lock:
|
|
@@ -351,9 +355,24 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
|
|
|
351
355
|
from langgraph.types import Command
|
|
352
356
|
agent_input = Command(resume=resume_data)
|
|
353
357
|
else:
|
|
354
|
-
|
|
358
|
+
# Inject workspace context into the message if available
|
|
359
|
+
if workspace_path:
|
|
360
|
+
context_prefix = f"[Current working directory: {workspace_path}]\n\n"
|
|
361
|
+
message_with_context = context_prefix + message
|
|
362
|
+
else:
|
|
363
|
+
message_with_context = message
|
|
364
|
+
agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
|
|
355
365
|
|
|
356
366
|
for update in agent.stream(agent_input, stream_mode="updates", config=stream_config):
|
|
367
|
+
# Check if stop was requested
|
|
368
|
+
with _agent_state_lock:
|
|
369
|
+
if _agent_state.get("stop_requested"):
|
|
370
|
+
_agent_state["response"] = _agent_state.get("response", "") + "\n\nAgent stopped by user."
|
|
371
|
+
_agent_state["running"] = False
|
|
372
|
+
_agent_state["stop_requested"] = False
|
|
373
|
+
_agent_state["last_update"] = time.time()
|
|
374
|
+
return
|
|
375
|
+
|
|
357
376
|
# Check for interrupt
|
|
358
377
|
if isinstance(update, dict) and "__interrupt__" in update:
|
|
359
378
|
interrupt_value = update["__interrupt__"]
|
|
@@ -388,13 +407,27 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
|
|
|
388
407
|
# Update tool call status when we get the result
|
|
389
408
|
tool_call_id = getattr(last_msg, 'tool_call_id', None)
|
|
390
409
|
if tool_call_id:
|
|
391
|
-
# Determine status
|
|
410
|
+
# Determine status - check message status attribute first
|
|
392
411
|
content = last_msg.content
|
|
393
412
|
status = "success"
|
|
394
|
-
|
|
413
|
+
|
|
414
|
+
# Check if ToolMessage has explicit status (e.g., from LangGraph)
|
|
415
|
+
msg_status = getattr(last_msg, 'status', None)
|
|
416
|
+
if msg_status == 'error':
|
|
395
417
|
status = "error"
|
|
418
|
+
# Check for dict with explicit error field
|
|
396
419
|
elif isinstance(content, dict) and content.get("error"):
|
|
397
420
|
status = "error"
|
|
421
|
+
# Check for common error patterns at the START of the message
|
|
422
|
+
# (not just anywhere, to avoid false positives)
|
|
423
|
+
elif isinstance(content, str):
|
|
424
|
+
content_lower = content.lower().strip()
|
|
425
|
+
# Only mark as error if it starts with error indicators
|
|
426
|
+
if (content_lower.startswith("error:") or
|
|
427
|
+
content_lower.startswith("failed:") or
|
|
428
|
+
content_lower.startswith("exception:") or
|
|
429
|
+
content_lower.startswith("traceback")):
|
|
430
|
+
status = "error"
|
|
398
431
|
|
|
399
432
|
# Truncate result for display
|
|
400
433
|
result_display = str(content)
|
|
@@ -469,6 +502,66 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
|
|
|
469
502
|
except Exception as e:
|
|
470
503
|
print(f"Failed to export canvas: {e}")
|
|
471
504
|
|
|
505
|
+
elif last_msg.name == 'update_canvas_item':
|
|
506
|
+
content = last_msg.content
|
|
507
|
+
# Parse the canvas item to update
|
|
508
|
+
if isinstance(content, str):
|
|
509
|
+
try:
|
|
510
|
+
canvas_item = json.loads(content)
|
|
511
|
+
except:
|
|
512
|
+
canvas_item = {"type": "markdown", "data": content}
|
|
513
|
+
elif isinstance(content, dict):
|
|
514
|
+
canvas_item = content
|
|
515
|
+
else:
|
|
516
|
+
canvas_item = {"type": "markdown", "data": str(content)}
|
|
517
|
+
|
|
518
|
+
item_id = canvas_item.get("id")
|
|
519
|
+
if item_id:
|
|
520
|
+
with _agent_state_lock:
|
|
521
|
+
# Find and replace the item with matching ID
|
|
522
|
+
for i, existing in enumerate(_agent_state["canvas"]):
|
|
523
|
+
if existing.get("id") == item_id:
|
|
524
|
+
_agent_state["canvas"][i] = canvas_item
|
|
525
|
+
break
|
|
526
|
+
else:
|
|
527
|
+
# If not found, append as new item
|
|
528
|
+
_agent_state["canvas"].append(canvas_item)
|
|
529
|
+
_agent_state["last_update"] = time.time()
|
|
530
|
+
|
|
531
|
+
# Export to markdown file
|
|
532
|
+
try:
|
|
533
|
+
export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
|
|
534
|
+
except Exception as e:
|
|
535
|
+
print(f"Failed to export canvas: {e}")
|
|
536
|
+
|
|
537
|
+
elif last_msg.name == 'remove_canvas_item':
|
|
538
|
+
content = last_msg.content
|
|
539
|
+
# Parse to get the item ID to remove
|
|
540
|
+
if isinstance(content, str):
|
|
541
|
+
try:
|
|
542
|
+
parsed = json.loads(content)
|
|
543
|
+
item_id = parsed.get("id")
|
|
544
|
+
except:
|
|
545
|
+
item_id = content # Assume string is the ID
|
|
546
|
+
elif isinstance(content, dict):
|
|
547
|
+
item_id = content.get("id")
|
|
548
|
+
else:
|
|
549
|
+
item_id = None
|
|
550
|
+
|
|
551
|
+
if item_id:
|
|
552
|
+
with _agent_state_lock:
|
|
553
|
+
_agent_state["canvas"] = [
|
|
554
|
+
item for item in _agent_state["canvas"]
|
|
555
|
+
if item.get("id") != item_id
|
|
556
|
+
]
|
|
557
|
+
_agent_state["last_update"] = time.time()
|
|
558
|
+
|
|
559
|
+
# Export to markdown file
|
|
560
|
+
try:
|
|
561
|
+
export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
|
|
562
|
+
except Exception as e:
|
|
563
|
+
print(f"Failed to export canvas: {e}")
|
|
564
|
+
|
|
472
565
|
elif last_msg.name in ('execute_cell', 'execute_all_cells'):
|
|
473
566
|
# Extract canvas_items from cell execution results
|
|
474
567
|
content = last_msg.content
|
|
@@ -635,12 +728,13 @@ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
|
635
728
|
|
|
636
729
|
return interrupt_data
|
|
637
730
|
|
|
638
|
-
def call_agent(message: str, resume_data: Dict = None):
|
|
731
|
+
def call_agent(message: str, resume_data: Dict = None, workspace_path: str = None):
|
|
639
732
|
"""Start agent execution in background thread.
|
|
640
733
|
|
|
641
734
|
Args:
|
|
642
735
|
message: User message to send to agent
|
|
643
736
|
resume_data: Optional dict with decisions to resume from interrupt
|
|
737
|
+
workspace_path: Current workspace directory path to inject into agent context
|
|
644
738
|
"""
|
|
645
739
|
# Reset state but preserve canvas - do it all atomically
|
|
646
740
|
with _agent_state_lock:
|
|
@@ -658,10 +752,11 @@ def call_agent(message: str, resume_data: Dict = None):
|
|
|
658
752
|
"interrupt": None, # Clear any previous interrupt
|
|
659
753
|
"last_update": time.time(),
|
|
660
754
|
"start_time": time.time(), # Track when agent started
|
|
755
|
+
"stop_requested": False, # Reset stop flag
|
|
661
756
|
})
|
|
662
757
|
|
|
663
758
|
# Start background thread
|
|
664
|
-
thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data))
|
|
759
|
+
thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data, workspace_path))
|
|
665
760
|
thread.daemon = True
|
|
666
761
|
thread.start()
|
|
667
762
|
|
|
@@ -780,7 +875,7 @@ app.index_string = '''<!DOCTYPE html>
|
|
|
780
875
|
<head>
|
|
781
876
|
{%metas%}
|
|
782
877
|
<title>{%title%}</title>
|
|
783
|
-
<link rel="icon" type="image/svg+xml" href="/assets/favicon.
|
|
878
|
+
<link rel="icon" type="image/svg+xml" href="/assets/favicon.ico">
|
|
784
879
|
{%css%}
|
|
785
880
|
</head>
|
|
786
881
|
<body>
|
|
@@ -800,13 +895,18 @@ app.index_string = '''<!DOCTYPE html>
|
|
|
800
895
|
|
|
801
896
|
def create_layout():
|
|
802
897
|
"""Create the app layout with current configuration."""
|
|
898
|
+
# Use agent's name/description if available, otherwise fall back to config
|
|
899
|
+
title = getattr(agent, 'name', None) or APP_TITLE
|
|
900
|
+
subtitle = getattr(agent, 'description', None) or APP_SUBTITLE
|
|
901
|
+
|
|
803
902
|
return create_layout_component(
|
|
804
903
|
workspace_root=WORKSPACE_ROOT,
|
|
805
|
-
app_title=
|
|
806
|
-
app_subtitle=
|
|
904
|
+
app_title=title,
|
|
905
|
+
app_subtitle=subtitle,
|
|
807
906
|
colors=COLORS,
|
|
808
907
|
styles=STYLES,
|
|
809
|
-
agent=agent
|
|
908
|
+
agent=agent,
|
|
909
|
+
welcome_message=WELCOME_MESSAGE
|
|
810
910
|
)
|
|
811
911
|
|
|
812
912
|
# Set layout as a function so it uses current WORKSPACE_ROOT
|
|
@@ -859,10 +959,11 @@ def display_initial_messages(history, theme):
|
|
|
859
959
|
Input("chat-input", "n_submit")],
|
|
860
960
|
[State("chat-input", "value"),
|
|
861
961
|
State("chat-history", "data"),
|
|
862
|
-
State("theme-store", "data")
|
|
962
|
+
State("theme-store", "data"),
|
|
963
|
+
State("current-workspace-path", "data")],
|
|
863
964
|
prevent_initial_call=True
|
|
864
965
|
)
|
|
865
|
-
def handle_send_immediate(n_clicks, n_submit, message, history, theme):
|
|
966
|
+
def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_workspace_path):
|
|
866
967
|
"""Phase 1: Immediately show user message and start agent."""
|
|
867
968
|
if not message or not message.strip():
|
|
868
969
|
raise PreventUpdate
|
|
@@ -891,8 +992,11 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme):
|
|
|
891
992
|
|
|
892
993
|
messages.append(format_loading(colors))
|
|
893
994
|
|
|
894
|
-
#
|
|
895
|
-
|
|
995
|
+
# Calculate full workspace path for agent context
|
|
996
|
+
workspace_full_path = WORKSPACE_ROOT / current_workspace_path if current_workspace_path else WORKSPACE_ROOT
|
|
997
|
+
|
|
998
|
+
# Start agent in background with workspace context
|
|
999
|
+
call_agent(message, workspace_path=str(workspace_full_path))
|
|
896
1000
|
|
|
897
1001
|
# Enable polling
|
|
898
1002
|
return messages, history, "", message, False
|
|
@@ -1042,6 +1146,72 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1042
1146
|
return messages, no_update, False
|
|
1043
1147
|
|
|
1044
1148
|
|
|
1149
|
+
# Stop button visibility - show when agent is running
|
|
1150
|
+
@app.callback(
|
|
1151
|
+
Output("stop-btn", "style"),
|
|
1152
|
+
Input("poll-interval", "n_intervals"),
|
|
1153
|
+
prevent_initial_call=True
|
|
1154
|
+
)
|
|
1155
|
+
def update_stop_button_visibility(n_intervals):
|
|
1156
|
+
"""Show stop button when agent is running, hide otherwise."""
|
|
1157
|
+
state = get_agent_state()
|
|
1158
|
+
if state.get("running"):
|
|
1159
|
+
return {} # Show button (remove display:none)
|
|
1160
|
+
else:
|
|
1161
|
+
return {"display": "none"} # Hide button
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
# Stop button click handler
|
|
1165
|
+
@app.callback(
|
|
1166
|
+
[Output("chat-messages", "children", allow_duplicate=True),
|
|
1167
|
+
Output("poll-interval", "disabled", allow_duplicate=True)],
|
|
1168
|
+
Input("stop-btn", "n_clicks"),
|
|
1169
|
+
[State("chat-history", "data"),
|
|
1170
|
+
State("theme-store", "data")],
|
|
1171
|
+
prevent_initial_call=True
|
|
1172
|
+
)
|
|
1173
|
+
def handle_stop_button(n_clicks, history, theme):
|
|
1174
|
+
"""Handle stop button click to stop agent execution."""
|
|
1175
|
+
if not n_clicks:
|
|
1176
|
+
raise PreventUpdate
|
|
1177
|
+
|
|
1178
|
+
colors = get_colors(theme or "light")
|
|
1179
|
+
history = history or []
|
|
1180
|
+
|
|
1181
|
+
# Request the agent to stop
|
|
1182
|
+
request_agent_stop()
|
|
1183
|
+
|
|
1184
|
+
# Render current messages with a stopping indicator
|
|
1185
|
+
def render_history_messages(history):
|
|
1186
|
+
messages = []
|
|
1187
|
+
for i, msg in enumerate(history):
|
|
1188
|
+
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
1189
|
+
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
|
|
1190
|
+
if msg.get("tool_calls"):
|
|
1191
|
+
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
1192
|
+
if tool_calls_block:
|
|
1193
|
+
messages.append(tool_calls_block)
|
|
1194
|
+
if msg.get("todos"):
|
|
1195
|
+
todos_block = format_todos_inline(msg["todos"], colors)
|
|
1196
|
+
if todos_block:
|
|
1197
|
+
messages.append(todos_block)
|
|
1198
|
+
return messages
|
|
1199
|
+
|
|
1200
|
+
messages = render_history_messages(history)
|
|
1201
|
+
|
|
1202
|
+
# Add stopping message
|
|
1203
|
+
messages.append(html.Div([
|
|
1204
|
+
html.Span("Stopping...", style={
|
|
1205
|
+
"fontSize": "15px",
|
|
1206
|
+
"fontWeight": "500",
|
|
1207
|
+
"color": colors["warning"],
|
|
1208
|
+
})
|
|
1209
|
+
], className="chat-message chat-message-loading", style={"padding": "12px 15px"}))
|
|
1210
|
+
|
|
1211
|
+
# Keep polling to detect when agent actually stops
|
|
1212
|
+
return messages, False
|
|
1213
|
+
|
|
1214
|
+
|
|
1045
1215
|
# Interrupt handling callbacks
|
|
1046
1216
|
@app.callback(
|
|
1047
1217
|
[Output("chat-messages", "children", allow_duplicate=True),
|
|
@@ -1120,13 +1290,14 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1120
1290
|
return messages, False
|
|
1121
1291
|
|
|
1122
1292
|
|
|
1123
|
-
# Folder toggle callback
|
|
1293
|
+
# Folder toggle callback - triggered by clicking the expand icon
|
|
1124
1294
|
@app.callback(
|
|
1125
1295
|
[Output({"type": "folder-children", "path": ALL}, "style"),
|
|
1126
1296
|
Output({"type": "folder-icon", "path": ALL}, "style"),
|
|
1127
1297
|
Output({"type": "folder-children", "path": ALL}, "children")],
|
|
1128
|
-
Input({"type": "folder-
|
|
1129
|
-
[State({"type": "folder-header", "path": ALL}, "
|
|
1298
|
+
Input({"type": "folder-icon", "path": ALL}, "n_clicks"),
|
|
1299
|
+
[State({"type": "folder-header", "path": ALL}, "id"),
|
|
1300
|
+
State({"type": "folder-header", "path": ALL}, "data-realpath"),
|
|
1130
1301
|
State({"type": "folder-children", "path": ALL}, "id"),
|
|
1131
1302
|
State({"type": "folder-icon", "path": ALL}, "id"),
|
|
1132
1303
|
State({"type": "folder-children", "path": ALL}, "style"),
|
|
@@ -1135,7 +1306,7 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1135
1306
|
State("theme-store", "data")],
|
|
1136
1307
|
prevent_initial_call=True
|
|
1137
1308
|
)
|
|
1138
|
-
def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
|
|
1309
|
+
def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
|
|
1139
1310
|
"""Toggle folder expansion and lazy load contents if needed."""
|
|
1140
1311
|
ctx = callback_context
|
|
1141
1312
|
if not ctx.triggered or not any(n_clicks):
|
|
@@ -1150,17 +1321,13 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1150
1321
|
except:
|
|
1151
1322
|
raise PreventUpdate
|
|
1152
1323
|
|
|
1153
|
-
#
|
|
1154
|
-
|
|
1155
|
-
for i,
|
|
1156
|
-
if
|
|
1157
|
-
|
|
1158
|
-
break
|
|
1159
|
-
|
|
1160
|
-
if clicked_idx is None:
|
|
1161
|
-
raise PreventUpdate
|
|
1324
|
+
# Build a mapping from folder path to real path using header_ids and real_paths
|
|
1325
|
+
path_to_realpath = {}
|
|
1326
|
+
for i, header_id in enumerate(header_ids):
|
|
1327
|
+
if i < len(real_paths):
|
|
1328
|
+
path_to_realpath[header_id["path"]] = real_paths[i]
|
|
1162
1329
|
|
|
1163
|
-
folder_rel_path =
|
|
1330
|
+
folder_rel_path = path_to_realpath.get(clicked_path)
|
|
1164
1331
|
if not folder_rel_path:
|
|
1165
1332
|
raise PreventUpdate
|
|
1166
1333
|
|
|
@@ -1189,7 +1356,7 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1189
1356
|
try:
|
|
1190
1357
|
folder_items = load_folder_contents(folder_rel_path, WORKSPACE_ROOT)
|
|
1191
1358
|
loaded_content = render_file_tree(folder_items, colors, STYLES,
|
|
1192
|
-
level=folder_rel_path.count("/") + 1,
|
|
1359
|
+
level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
|
|
1193
1360
|
parent_path=folder_rel_path)
|
|
1194
1361
|
new_children_content.append(loaded_content if loaded_content else current_content)
|
|
1195
1362
|
except Exception as e:
|
|
@@ -1215,11 +1382,11 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1215
1382
|
current_children_style = children_styles[children_idx] if children_idx < len(children_styles) else {"display": "none"}
|
|
1216
1383
|
is_expanded = current_children_style.get("display") != "none"
|
|
1217
1384
|
new_icon_styles.append({
|
|
1218
|
-
"marginRight": "
|
|
1385
|
+
"marginRight": "5px",
|
|
1219
1386
|
"fontSize": "10px",
|
|
1220
|
-
"
|
|
1221
|
-
"transition": "transform 0.2s",
|
|
1387
|
+
"transition": "transform 0.15s",
|
|
1222
1388
|
"display": "inline-block",
|
|
1389
|
+
"padding": "2px",
|
|
1223
1390
|
"transform": "rotate(0deg)" if is_expanded else "rotate(90deg)",
|
|
1224
1391
|
})
|
|
1225
1392
|
else:
|
|
@@ -1230,6 +1397,129 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1230
1397
|
return new_children_styles, new_icon_styles, new_children_content
|
|
1231
1398
|
|
|
1232
1399
|
|
|
1400
|
+
# Enter folder callback - triggered by double-clicking folder name (changes workspace root)
|
|
1401
|
+
@app.callback(
|
|
1402
|
+
[Output("current-workspace-path", "data"),
|
|
1403
|
+
Output("workspace-breadcrumb", "children"),
|
|
1404
|
+
Output("file-tree", "children", allow_duplicate=True)],
|
|
1405
|
+
[Input({"type": "folder-select", "path": ALL}, "n_clicks"),
|
|
1406
|
+
Input("breadcrumb-root", "n_clicks"),
|
|
1407
|
+
Input({"type": "breadcrumb-segment", "index": ALL}, "n_clicks")],
|
|
1408
|
+
[State({"type": "folder-select", "path": ALL}, "id"),
|
|
1409
|
+
State({"type": "folder-select", "path": ALL}, "data-folderpath"),
|
|
1410
|
+
State({"type": "folder-select", "path": ALL}, "n_clicks"),
|
|
1411
|
+
State("current-workspace-path", "data"),
|
|
1412
|
+
State("theme-store", "data")],
|
|
1413
|
+
prevent_initial_call=True
|
|
1414
|
+
)
|
|
1415
|
+
def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, folder_paths, prev_clicks, current_path, theme):
|
|
1416
|
+
"""Enter a folder (double-click) or navigate via breadcrumb."""
|
|
1417
|
+
ctx = callback_context
|
|
1418
|
+
if not ctx.triggered:
|
|
1419
|
+
raise PreventUpdate
|
|
1420
|
+
|
|
1421
|
+
colors = get_colors(theme or "light")
|
|
1422
|
+
triggered = ctx.triggered[0]["prop_id"]
|
|
1423
|
+
|
|
1424
|
+
new_path = current_path or ""
|
|
1425
|
+
|
|
1426
|
+
# Check if breadcrumb root was clicked
|
|
1427
|
+
if "breadcrumb-root" in triggered:
|
|
1428
|
+
new_path = ""
|
|
1429
|
+
# Check if a breadcrumb segment was clicked
|
|
1430
|
+
elif "breadcrumb-segment" in triggered:
|
|
1431
|
+
try:
|
|
1432
|
+
id_str = triggered.rsplit(".", 1)[0]
|
|
1433
|
+
id_dict = json.loads(id_str)
|
|
1434
|
+
segment_index = id_dict.get("index")
|
|
1435
|
+
# Build path up to this segment
|
|
1436
|
+
if current_path:
|
|
1437
|
+
parts = current_path.split("/")
|
|
1438
|
+
new_path = "/".join(parts[:segment_index + 1])
|
|
1439
|
+
else:
|
|
1440
|
+
new_path = ""
|
|
1441
|
+
except:
|
|
1442
|
+
raise PreventUpdate
|
|
1443
|
+
# Check if a folder was double-clicked (n_clicks >= 2 and increased by 1)
|
|
1444
|
+
elif "folder-select" in triggered:
|
|
1445
|
+
try:
|
|
1446
|
+
id_str = triggered.rsplit(".", 1)[0]
|
|
1447
|
+
id_dict = json.loads(id_str)
|
|
1448
|
+
clicked_path = id_dict.get("path")
|
|
1449
|
+
except:
|
|
1450
|
+
raise PreventUpdate
|
|
1451
|
+
|
|
1452
|
+
# Find the folder and check for double-click
|
|
1453
|
+
for i, folder_id in enumerate(folder_ids):
|
|
1454
|
+
if folder_id["path"] == clicked_path:
|
|
1455
|
+
current_clicks = folder_clicks[i] if i < len(folder_clicks) else 0
|
|
1456
|
+
previous_clicks = prev_clicks[i] if i < len(prev_clicks) else 0
|
|
1457
|
+
|
|
1458
|
+
# Only enter on double-click (clicks increased and is even number >= 2)
|
|
1459
|
+
if current_clicks and current_clicks >= 2 and current_clicks % 2 == 0:
|
|
1460
|
+
folder_rel_path = folder_paths[i] if i < len(folder_paths) else ""
|
|
1461
|
+
# Combine with current workspace path
|
|
1462
|
+
if current_path:
|
|
1463
|
+
new_path = f"{current_path}/{folder_rel_path}"
|
|
1464
|
+
else:
|
|
1465
|
+
new_path = folder_rel_path
|
|
1466
|
+
else:
|
|
1467
|
+
# Single click - don't change workspace
|
|
1468
|
+
raise PreventUpdate
|
|
1469
|
+
break
|
|
1470
|
+
else:
|
|
1471
|
+
raise PreventUpdate
|
|
1472
|
+
else:
|
|
1473
|
+
raise PreventUpdate
|
|
1474
|
+
|
|
1475
|
+
# Build breadcrumb navigation
|
|
1476
|
+
breadcrumb_children = [
|
|
1477
|
+
html.Span([
|
|
1478
|
+
DashIconify(icon="mdi:home", width=14, style={"marginRight": "4px"}),
|
|
1479
|
+
"root"
|
|
1480
|
+
], id="breadcrumb-root", className="breadcrumb-item breadcrumb-clickable", style={
|
|
1481
|
+
"display": "inline-flex",
|
|
1482
|
+
"alignItems": "center",
|
|
1483
|
+
"cursor": "pointer",
|
|
1484
|
+
"padding": "2px 6px",
|
|
1485
|
+
"borderRadius": "3px",
|
|
1486
|
+
}),
|
|
1487
|
+
]
|
|
1488
|
+
|
|
1489
|
+
if new_path:
|
|
1490
|
+
parts = new_path.split("/")
|
|
1491
|
+
for i, part in enumerate(parts):
|
|
1492
|
+
# Add separator
|
|
1493
|
+
breadcrumb_children.append(
|
|
1494
|
+
html.Span(" / ", className="breadcrumb-separator", style={
|
|
1495
|
+
"color": "var(--mantine-color-dimmed)",
|
|
1496
|
+
"margin": "0 2px",
|
|
1497
|
+
})
|
|
1498
|
+
)
|
|
1499
|
+
# Add clickable segment
|
|
1500
|
+
breadcrumb_children.append(
|
|
1501
|
+
html.Span(part, id={"type": "breadcrumb-segment", "index": i},
|
|
1502
|
+
className="breadcrumb-item breadcrumb-clickable",
|
|
1503
|
+
style={
|
|
1504
|
+
"cursor": "pointer",
|
|
1505
|
+
"padding": "2px 6px",
|
|
1506
|
+
"borderRadius": "3px",
|
|
1507
|
+
}
|
|
1508
|
+
)
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
# Calculate the actual workspace path
|
|
1512
|
+
workspace_full_path = WORKSPACE_ROOT / new_path if new_path else WORKSPACE_ROOT
|
|
1513
|
+
|
|
1514
|
+
# Render new file tree
|
|
1515
|
+
file_tree = render_file_tree(
|
|
1516
|
+
build_file_tree(workspace_full_path, workspace_full_path),
|
|
1517
|
+
colors, STYLES
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
return new_path, breadcrumb_children, file_tree
|
|
1521
|
+
|
|
1522
|
+
|
|
1233
1523
|
# File click - open modal
|
|
1234
1524
|
@app.callback(
|
|
1235
1525
|
[Output("file-modal", "opened"),
|
|
@@ -1404,69 +1694,149 @@ def open_terminal(n_clicks):
|
|
|
1404
1694
|
[Output("file-tree", "children"),
|
|
1405
1695
|
Output("canvas-content", "children", allow_duplicate=True)],
|
|
1406
1696
|
Input("refresh-btn", "n_clicks"),
|
|
1407
|
-
[State("
|
|
1697
|
+
[State("current-workspace-path", "data"),
|
|
1698
|
+
State("theme-store", "data"),
|
|
1699
|
+
State("collapsed-canvas-items", "data")],
|
|
1408
1700
|
prevent_initial_call=True
|
|
1409
1701
|
)
|
|
1410
|
-
def refresh_sidebar(n_clicks, theme):
|
|
1702
|
+
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
|
|
1411
1703
|
"""Refresh both file tree and canvas content."""
|
|
1412
1704
|
global _agent_state
|
|
1413
1705
|
colors = get_colors(theme or "light")
|
|
1706
|
+
collapsed_ids = collapsed_ids or []
|
|
1414
1707
|
|
|
1415
|
-
#
|
|
1416
|
-
|
|
1708
|
+
# Calculate current workspace directory
|
|
1709
|
+
current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
|
|
1417
1710
|
|
|
1418
|
-
# Refresh
|
|
1711
|
+
# Refresh file tree for current workspace
|
|
1712
|
+
file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
1713
|
+
|
|
1714
|
+
# Refresh canvas by reloading from .canvas/canvas.md file (always from original root)
|
|
1419
1715
|
canvas_items = load_canvas_from_markdown(WORKSPACE_ROOT)
|
|
1420
1716
|
|
|
1421
1717
|
# Update agent state with reloaded canvas
|
|
1422
1718
|
with _agent_state_lock:
|
|
1423
1719
|
_agent_state["canvas"] = canvas_items
|
|
1424
1720
|
|
|
1425
|
-
# Render the canvas items
|
|
1426
|
-
canvas_content = render_canvas_items(canvas_items, colors)
|
|
1721
|
+
# Render the canvas items with preserved collapsed state
|
|
1722
|
+
canvas_content = render_canvas_items(canvas_items, colors, collapsed_ids)
|
|
1427
1723
|
|
|
1428
1724
|
return file_tree, canvas_content
|
|
1429
1725
|
|
|
1430
1726
|
|
|
1431
|
-
# File upload
|
|
1727
|
+
# File upload (sidebar button) - uploads to current workspace directory
|
|
1432
1728
|
@app.callback(
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1729
|
+
Output("file-tree", "children", allow_duplicate=True),
|
|
1730
|
+
Input("file-upload-sidebar", "contents"),
|
|
1731
|
+
[State("file-upload-sidebar", "filename"),
|
|
1732
|
+
State("current-workspace-path", "data"),
|
|
1437
1733
|
State("theme-store", "data")],
|
|
1438
1734
|
prevent_initial_call=True
|
|
1439
1735
|
)
|
|
1440
|
-
def
|
|
1441
|
-
"""Handle file uploads."""
|
|
1736
|
+
def handle_sidebar_upload(contents, filenames, current_workspace, theme):
|
|
1737
|
+
"""Handle file uploads from sidebar button to current workspace."""
|
|
1442
1738
|
if not contents:
|
|
1443
1739
|
raise PreventUpdate
|
|
1444
1740
|
|
|
1445
1741
|
colors = get_colors(theme or "light")
|
|
1446
|
-
|
|
1742
|
+
# Calculate current workspace directory
|
|
1743
|
+
current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
|
|
1744
|
+
|
|
1447
1745
|
for content, filename in zip(contents, filenames):
|
|
1448
1746
|
try:
|
|
1449
1747
|
_, content_string = content.split(',')
|
|
1450
1748
|
decoded = base64.b64decode(content_string)
|
|
1451
|
-
file_path =
|
|
1749
|
+
file_path = current_workspace_dir / filename
|
|
1452
1750
|
try:
|
|
1453
1751
|
file_path.write_text(decoded.decode('utf-8'))
|
|
1454
1752
|
except UnicodeDecodeError:
|
|
1455
1753
|
file_path.write_bytes(decoded)
|
|
1456
|
-
uploaded.append(filename)
|
|
1457
1754
|
except Exception as e:
|
|
1458
1755
|
print(f"Upload error: {e}")
|
|
1459
1756
|
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1757
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
# Create folder modal - open
|
|
1761
|
+
@app.callback(
|
|
1762
|
+
Output("create-folder-modal", "opened"),
|
|
1763
|
+
[Input("create-folder-btn", "n_clicks"),
|
|
1764
|
+
Input("cancel-folder-btn", "n_clicks"),
|
|
1765
|
+
Input("confirm-folder-btn", "n_clicks")],
|
|
1766
|
+
[State("create-folder-modal", "opened"),
|
|
1767
|
+
State("new-folder-name", "value")],
|
|
1768
|
+
prevent_initial_call=True
|
|
1769
|
+
)
|
|
1770
|
+
def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_open, folder_name):
|
|
1771
|
+
"""Open/close the create folder modal."""
|
|
1772
|
+
ctx = callback_context
|
|
1773
|
+
if not ctx.triggered:
|
|
1774
|
+
raise PreventUpdate
|
|
1775
|
+
|
|
1776
|
+
trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
1777
|
+
|
|
1778
|
+
if trigger_id == "create-folder-btn":
|
|
1779
|
+
return True
|
|
1780
|
+
elif trigger_id == "cancel-folder-btn":
|
|
1781
|
+
return False
|
|
1782
|
+
elif trigger_id == "confirm-folder-btn":
|
|
1783
|
+
# Close modal only if folder name is provided
|
|
1784
|
+
if folder_name and folder_name.strip():
|
|
1785
|
+
return False
|
|
1786
|
+
return True # Keep open if no name provided
|
|
1787
|
+
|
|
1788
|
+
return is_open
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
# Create folder - action
|
|
1792
|
+
@app.callback(
|
|
1793
|
+
[Output("file-tree", "children", allow_duplicate=True),
|
|
1794
|
+
Output("create-folder-error", "children"),
|
|
1795
|
+
Output("new-folder-name", "value")],
|
|
1796
|
+
Input("confirm-folder-btn", "n_clicks"),
|
|
1797
|
+
[State("new-folder-name", "value"),
|
|
1798
|
+
State("current-workspace-path", "data"),
|
|
1799
|
+
State("theme-store", "data")],
|
|
1800
|
+
prevent_initial_call=True
|
|
1801
|
+
)
|
|
1802
|
+
def create_folder(n_clicks, folder_name, current_workspace, theme):
|
|
1803
|
+
"""Create a new folder in the current workspace directory."""
|
|
1804
|
+
if not n_clicks:
|
|
1805
|
+
raise PreventUpdate
|
|
1806
|
+
|
|
1807
|
+
colors = get_colors(theme or "light")
|
|
1808
|
+
|
|
1809
|
+
if not folder_name or not folder_name.strip():
|
|
1810
|
+
return no_update, "Please enter a folder name", no_update
|
|
1811
|
+
|
|
1812
|
+
folder_name = folder_name.strip()
|
|
1813
|
+
|
|
1814
|
+
# Validate folder name
|
|
1815
|
+
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
|
1816
|
+
if any(char in folder_name for char in invalid_chars):
|
|
1817
|
+
return no_update, f"Folder name cannot contain: {' '.join(invalid_chars)}", no_update
|
|
1818
|
+
|
|
1819
|
+
# Calculate current workspace directory
|
|
1820
|
+
current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
|
|
1821
|
+
folder_path = current_workspace_dir / folder_name
|
|
1822
|
+
|
|
1823
|
+
if folder_path.exists():
|
|
1824
|
+
return no_update, f"Folder '{folder_name}' already exists", no_update
|
|
1825
|
+
|
|
1826
|
+
try:
|
|
1827
|
+
folder_path.mkdir(parents=True, exist_ok=False)
|
|
1828
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES), "", ""
|
|
1829
|
+
except Exception as e:
|
|
1830
|
+
return no_update, f"Error creating folder: {e}", no_update
|
|
1463
1831
|
|
|
1464
1832
|
|
|
1465
1833
|
# View toggle callbacks - using SegmentedControl
|
|
1466
1834
|
@app.callback(
|
|
1467
1835
|
[Output("files-view", "style"),
|
|
1468
1836
|
Output("canvas-view", "style"),
|
|
1469
|
-
Output("open-terminal-btn", "style")
|
|
1837
|
+
Output("open-terminal-btn", "style"),
|
|
1838
|
+
Output("create-folder-btn", "style"),
|
|
1839
|
+
Output("file-upload-sidebar", "style")],
|
|
1470
1840
|
[Input("sidebar-view-toggle", "value")],
|
|
1471
1841
|
prevent_initial_call=True
|
|
1472
1842
|
)
|
|
@@ -1476,7 +1846,7 @@ def toggle_view(view_value):
|
|
|
1476
1846
|
raise PreventUpdate
|
|
1477
1847
|
|
|
1478
1848
|
if view_value == "canvas":
|
|
1479
|
-
# Show canvas, hide files, hide
|
|
1849
|
+
# Show canvas, hide files, hide file-related buttons
|
|
1480
1850
|
return (
|
|
1481
1851
|
{"flex": "1", "display": "none", "flexDirection": "column"},
|
|
1482
1852
|
{
|
|
@@ -1486,10 +1856,12 @@ def toggle_view(view_value):
|
|
|
1486
1856
|
"flexDirection": "column",
|
|
1487
1857
|
"overflow": "hidden"
|
|
1488
1858
|
},
|
|
1489
|
-
{"display": "none"} # Hide terminal button
|
|
1859
|
+
{"display": "none"}, # Hide terminal button
|
|
1860
|
+
{"display": "none"}, # Hide create folder button
|
|
1861
|
+
{"display": "none"}, # Hide file upload button
|
|
1490
1862
|
)
|
|
1491
1863
|
else:
|
|
1492
|
-
# Show files, hide canvas, show
|
|
1864
|
+
# Show files, hide canvas, show file-related buttons
|
|
1493
1865
|
return (
|
|
1494
1866
|
{
|
|
1495
1867
|
"flex": "1",
|
|
@@ -1505,7 +1877,9 @@ def toggle_view(view_value):
|
|
|
1505
1877
|
"flexDirection": "column",
|
|
1506
1878
|
"overflow": "hidden"
|
|
1507
1879
|
},
|
|
1508
|
-
{} # Show terminal button (default styles)
|
|
1880
|
+
{}, # Show terminal button (default styles)
|
|
1881
|
+
{}, # Show create folder button (default styles)
|
|
1882
|
+
{}, # Show file upload button (default styles)
|
|
1509
1883
|
)
|
|
1510
1884
|
|
|
1511
1885
|
|
|
@@ -1514,78 +1888,274 @@ def toggle_view(view_value):
|
|
|
1514
1888
|
Output("canvas-content", "children"),
|
|
1515
1889
|
[Input("poll-interval", "n_intervals"),
|
|
1516
1890
|
Input("sidebar-view-toggle", "value")],
|
|
1517
|
-
[State("theme-store", "data")
|
|
1891
|
+
[State("theme-store", "data"),
|
|
1892
|
+
State("collapsed-canvas-items", "data")],
|
|
1518
1893
|
prevent_initial_call=False
|
|
1519
1894
|
)
|
|
1520
|
-
def update_canvas_content(n_intervals, view_value, theme):
|
|
1895
|
+
def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
|
|
1521
1896
|
"""Update canvas content from agent state."""
|
|
1522
1897
|
state = get_agent_state()
|
|
1523
1898
|
canvas_items = state.get("canvas", [])
|
|
1524
1899
|
colors = get_colors(theme or "light")
|
|
1900
|
+
collapsed_ids = collapsed_ids or []
|
|
1525
1901
|
|
|
1526
|
-
# Use imported rendering function
|
|
1527
|
-
return render_canvas_items(canvas_items, colors)
|
|
1902
|
+
# Use imported rendering function with preserved collapsed state
|
|
1903
|
+
return render_canvas_items(canvas_items, colors, collapsed_ids)
|
|
1528
1904
|
|
|
1529
1905
|
|
|
1530
1906
|
|
|
1531
|
-
#
|
|
1907
|
+
# Open clear canvas confirmation modal
|
|
1532
1908
|
@app.callback(
|
|
1533
|
-
Output("canvas-
|
|
1909
|
+
Output("clear-canvas-modal", "opened"),
|
|
1534
1910
|
Input("clear-canvas-btn", "n_clicks"),
|
|
1535
|
-
[State("theme-store", "data")],
|
|
1536
1911
|
prevent_initial_call=True
|
|
1537
1912
|
)
|
|
1538
|
-
def
|
|
1539
|
-
"""
|
|
1913
|
+
def open_clear_canvas_modal(n_clicks):
|
|
1914
|
+
"""Open the clear canvas confirmation modal."""
|
|
1540
1915
|
if not n_clicks:
|
|
1541
1916
|
raise PreventUpdate
|
|
1917
|
+
return True
|
|
1542
1918
|
|
|
1543
|
-
global _agent_state
|
|
1544
|
-
colors = get_colors(theme or "light")
|
|
1545
1919
|
|
|
1546
|
-
|
|
1920
|
+
# Handle clear canvas confirmation
|
|
1921
|
+
@app.callback(
|
|
1922
|
+
[Output("canvas-content", "children", allow_duplicate=True),
|
|
1923
|
+
Output("clear-canvas-modal", "opened", allow_duplicate=True),
|
|
1924
|
+
Output("collapsed-canvas-items", "data", allow_duplicate=True)],
|
|
1925
|
+
[Input("confirm-clear-canvas-btn", "n_clicks"),
|
|
1926
|
+
Input("cancel-clear-canvas-btn", "n_clicks")],
|
|
1927
|
+
[State("theme-store", "data")],
|
|
1928
|
+
prevent_initial_call=True
|
|
1929
|
+
)
|
|
1930
|
+
def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
|
|
1931
|
+
"""Handle the clear canvas confirmation - either clear or cancel."""
|
|
1932
|
+
ctx = callback_context
|
|
1933
|
+
if not ctx.triggered:
|
|
1934
|
+
raise PreventUpdate
|
|
1547
1935
|
|
|
1548
|
-
|
|
1549
|
-
canvas_dir = WORKSPACE_ROOT / ".canvas"
|
|
1550
|
-
if canvas_dir.exists() and canvas_dir.is_dir():
|
|
1551
|
-
try:
|
|
1552
|
-
archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
|
|
1553
|
-
shutil.move(str(canvas_dir), str(archive_dir))
|
|
1554
|
-
print(f"Archived .canvas folder to {archive_dir}")
|
|
1555
|
-
except Exception as e:
|
|
1556
|
-
print(f"Failed to archive .canvas folder: {e}")
|
|
1936
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
1557
1937
|
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1938
|
+
if triggered_id == "cancel-clear-canvas-btn":
|
|
1939
|
+
# Close modal without clearing
|
|
1940
|
+
return no_update, False, no_update
|
|
1941
|
+
|
|
1942
|
+
if triggered_id == "confirm-clear-canvas-btn":
|
|
1943
|
+
if not confirm_clicks:
|
|
1944
|
+
raise PreventUpdate
|
|
1945
|
+
|
|
1946
|
+
global _agent_state
|
|
1947
|
+
colors = get_colors(theme or "light")
|
|
1948
|
+
|
|
1949
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1950
|
+
|
|
1951
|
+
# Archive .canvas folder if it exists (contains canvas.md and all assets)
|
|
1952
|
+
canvas_dir = WORKSPACE_ROOT / ".canvas"
|
|
1953
|
+
if canvas_dir.exists() and canvas_dir.is_dir():
|
|
1954
|
+
try:
|
|
1955
|
+
archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
|
|
1956
|
+
shutil.move(str(canvas_dir), str(archive_dir))
|
|
1957
|
+
print(f"Archived .canvas folder to {archive_dir}")
|
|
1958
|
+
except Exception as e:
|
|
1959
|
+
print(f"Failed to archive .canvas folder: {e}")
|
|
1960
|
+
|
|
1961
|
+
# Clear canvas in state
|
|
1962
|
+
with _agent_state_lock:
|
|
1963
|
+
_agent_state["canvas"] = []
|
|
1964
|
+
|
|
1965
|
+
# Return empty state, close modal, and clear collapsed items
|
|
1966
|
+
return html.Div([
|
|
1967
|
+
html.Div("🗒", style={
|
|
1968
|
+
"fontSize": "48px",
|
|
1969
|
+
"textAlign": "center",
|
|
1970
|
+
"marginBottom": "16px",
|
|
1971
|
+
"opacity": "0.3"
|
|
1972
|
+
}),
|
|
1973
|
+
html.P("Canvas is empty", style={
|
|
1974
|
+
"textAlign": "center",
|
|
1975
|
+
"color": colors["text_muted"],
|
|
1976
|
+
"fontSize": "14px"
|
|
1977
|
+
}),
|
|
1978
|
+
html.P("The agent will add visualizations, charts, and notes here", style={
|
|
1979
|
+
"textAlign": "center",
|
|
1980
|
+
"color": colors["text_muted"],
|
|
1981
|
+
"fontSize": "12px",
|
|
1982
|
+
"marginTop": "8px"
|
|
1983
|
+
})
|
|
1984
|
+
], style={
|
|
1985
|
+
"display": "flex",
|
|
1986
|
+
"flexDirection": "column",
|
|
1987
|
+
"alignItems": "center",
|
|
1988
|
+
"justifyContent": "center",
|
|
1989
|
+
"height": "100%",
|
|
1990
|
+
"padding": "40px"
|
|
1991
|
+
}), False, []
|
|
1992
|
+
|
|
1993
|
+
raise PreventUpdate
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
# Collapse/expand canvas item callback
|
|
1997
|
+
@app.callback(
|
|
1998
|
+
[Output({"type": "canvas-item-content", "index": ALL}, "style"),
|
|
1999
|
+
Output({"type": "canvas-collapse-btn", "index": ALL}, "children"),
|
|
2000
|
+
Output("collapsed-canvas-items", "data")],
|
|
2001
|
+
Input({"type": "canvas-collapse-btn", "index": ALL}, "n_clicks"),
|
|
2002
|
+
[State({"type": "canvas-collapse-btn", "index": ALL}, "id"),
|
|
2003
|
+
State({"type": "canvas-item-content", "index": ALL}, "style"),
|
|
2004
|
+
State({"type": "canvas-item-content", "index": ALL}, "id"),
|
|
2005
|
+
State("collapsed-canvas-items", "data")],
|
|
2006
|
+
prevent_initial_call=True
|
|
2007
|
+
)
|
|
2008
|
+
def toggle_canvas_item_collapse(all_clicks, btn_ids, content_styles, content_ids, collapsed_ids):
|
|
2009
|
+
"""Toggle collapse/expand state of a canvas item."""
|
|
2010
|
+
ctx = callback_context
|
|
2011
|
+
if not ctx.triggered:
|
|
2012
|
+
raise PreventUpdate
|
|
2013
|
+
|
|
2014
|
+
# Find which button was clicked
|
|
2015
|
+
triggered = ctx.triggered[0]
|
|
2016
|
+
triggered_id = triggered["prop_id"]
|
|
2017
|
+
triggered_value = triggered.get("value")
|
|
2018
|
+
|
|
2019
|
+
if not triggered_value or triggered_value <= 0:
|
|
2020
|
+
raise PreventUpdate
|
|
2021
|
+
|
|
2022
|
+
try:
|
|
2023
|
+
id_str = triggered_id.rsplit(".", 1)[0]
|
|
2024
|
+
id_dict = json.loads(id_str)
|
|
2025
|
+
clicked_item_id = id_dict.get("index")
|
|
2026
|
+
except:
|
|
2027
|
+
raise PreventUpdate
|
|
2028
|
+
|
|
2029
|
+
if not clicked_item_id:
|
|
2030
|
+
raise PreventUpdate
|
|
2031
|
+
|
|
2032
|
+
# Initialize collapsed_ids if None
|
|
2033
|
+
collapsed_ids = collapsed_ids or []
|
|
2034
|
+
|
|
2035
|
+
# Build new styles and icons for all items
|
|
2036
|
+
new_styles = []
|
|
2037
|
+
new_icons = []
|
|
2038
|
+
new_collapsed_ids = collapsed_ids.copy()
|
|
2039
|
+
|
|
2040
|
+
for i, content_id in enumerate(content_ids):
|
|
2041
|
+
item_id = content_id.get("index")
|
|
2042
|
+
current_style = content_styles[i] if i < len(content_styles) else {"display": "block"}
|
|
2043
|
+
|
|
2044
|
+
if item_id == clicked_item_id:
|
|
2045
|
+
# Toggle this item
|
|
2046
|
+
is_collapsed = current_style.get("display") == "none"
|
|
2047
|
+
new_styles.append({"display": "block"} if is_collapsed else {"display": "none"})
|
|
2048
|
+
# Change icon based on new state
|
|
2049
|
+
new_icons.append(
|
|
2050
|
+
DashIconify(icon="mdi:chevron-down" if is_collapsed else "mdi:chevron-right", width=16)
|
|
2051
|
+
)
|
|
2052
|
+
# Update collapsed_ids list
|
|
2053
|
+
if is_collapsed:
|
|
2054
|
+
# Was collapsed, now expanding - remove from list
|
|
2055
|
+
if item_id in new_collapsed_ids:
|
|
2056
|
+
new_collapsed_ids.remove(item_id)
|
|
2057
|
+
else:
|
|
2058
|
+
# Was expanded, now collapsing - add to list
|
|
2059
|
+
if item_id not in new_collapsed_ids:
|
|
2060
|
+
new_collapsed_ids.append(item_id)
|
|
2061
|
+
else:
|
|
2062
|
+
new_styles.append(current_style)
|
|
2063
|
+
# Keep existing icon state
|
|
2064
|
+
is_collapsed = current_style.get("display") == "none"
|
|
2065
|
+
new_icons.append(
|
|
2066
|
+
DashIconify(icon="mdi:chevron-right" if is_collapsed else "mdi:chevron-down", width=16)
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
return new_styles, new_icons, new_collapsed_ids
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
# Open delete confirmation modal
|
|
2073
|
+
@app.callback(
|
|
2074
|
+
[Output("delete-canvas-item-modal", "opened"),
|
|
2075
|
+
Output("delete-canvas-item-id", "data")],
|
|
2076
|
+
Input({"type": "canvas-delete-btn", "index": ALL}, "n_clicks"),
|
|
2077
|
+
[State({"type": "canvas-delete-btn", "index": ALL}, "id")],
|
|
2078
|
+
prevent_initial_call=True
|
|
2079
|
+
)
|
|
2080
|
+
def open_delete_confirmation(all_clicks, all_ids):
|
|
2081
|
+
"""Open the delete confirmation modal when delete button is clicked."""
|
|
2082
|
+
ctx = callback_context
|
|
2083
|
+
if not ctx.triggered:
|
|
2084
|
+
raise PreventUpdate
|
|
2085
|
+
|
|
2086
|
+
triggered = ctx.triggered[0]
|
|
2087
|
+
triggered_id = triggered["prop_id"]
|
|
2088
|
+
triggered_value = triggered.get("value")
|
|
2089
|
+
|
|
2090
|
+
if not triggered_value or triggered_value <= 0:
|
|
2091
|
+
raise PreventUpdate
|
|
2092
|
+
|
|
2093
|
+
try:
|
|
2094
|
+
id_str = triggered_id.rsplit(".", 1)[0]
|
|
2095
|
+
id_dict = json.loads(id_str)
|
|
2096
|
+
item_id_to_delete = id_dict.get("index")
|
|
2097
|
+
except:
|
|
2098
|
+
raise PreventUpdate
|
|
2099
|
+
|
|
2100
|
+
if not item_id_to_delete:
|
|
2101
|
+
raise PreventUpdate
|
|
2102
|
+
|
|
2103
|
+
return True, item_id_to_delete
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
# Handle delete confirmation modal actions
|
|
2107
|
+
@app.callback(
|
|
2108
|
+
[Output("canvas-content", "children", allow_duplicate=True),
|
|
2109
|
+
Output("delete-canvas-item-modal", "opened", allow_duplicate=True),
|
|
2110
|
+
Output("collapsed-canvas-items", "data", allow_duplicate=True)],
|
|
2111
|
+
[Input("confirm-delete-canvas-btn", "n_clicks"),
|
|
2112
|
+
Input("cancel-delete-canvas-btn", "n_clicks")],
|
|
2113
|
+
[State("delete-canvas-item-id", "data"),
|
|
2114
|
+
State("theme-store", "data"),
|
|
2115
|
+
State("collapsed-canvas-items", "data")],
|
|
2116
|
+
prevent_initial_call=True
|
|
2117
|
+
)
|
|
2118
|
+
def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, collapsed_ids):
|
|
2119
|
+
"""Handle the delete confirmation - either delete or cancel."""
|
|
2120
|
+
ctx = callback_context
|
|
2121
|
+
if not ctx.triggered:
|
|
2122
|
+
raise PreventUpdate
|
|
2123
|
+
|
|
2124
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
2125
|
+
|
|
2126
|
+
if triggered_id == "cancel-delete-canvas-btn":
|
|
2127
|
+
# Close modal without deleting
|
|
2128
|
+
return no_update, False, no_update
|
|
2129
|
+
|
|
2130
|
+
if triggered_id == "confirm-delete-canvas-btn":
|
|
2131
|
+
if not confirm_clicks or not item_id:
|
|
2132
|
+
raise PreventUpdate
|
|
2133
|
+
|
|
2134
|
+
global _agent_state
|
|
2135
|
+
colors = get_colors(theme or "light")
|
|
2136
|
+
collapsed_ids = collapsed_ids or []
|
|
2137
|
+
|
|
2138
|
+
# Remove the item from canvas
|
|
2139
|
+
with _agent_state_lock:
|
|
2140
|
+
_agent_state["canvas"] = [
|
|
2141
|
+
item for item in _agent_state["canvas"]
|
|
2142
|
+
if item.get("id") != item_id
|
|
2143
|
+
]
|
|
2144
|
+
canvas_items = _agent_state["canvas"].copy()
|
|
2145
|
+
|
|
2146
|
+
# Export updated canvas to markdown file
|
|
2147
|
+
try:
|
|
2148
|
+
export_canvas_to_markdown(canvas_items, WORKSPACE_ROOT)
|
|
2149
|
+
except Exception as e:
|
|
2150
|
+
print(f"Failed to export canvas after delete: {e}")
|
|
2151
|
+
|
|
2152
|
+
# Remove deleted item from collapsed_ids if present
|
|
2153
|
+
new_collapsed_ids = [cid for cid in collapsed_ids if cid != item_id]
|
|
2154
|
+
|
|
2155
|
+
# Render updated canvas with preserved collapsed state and close modal
|
|
2156
|
+
return render_canvas_items(canvas_items, colors, new_collapsed_ids), False, new_collapsed_ids
|
|
2157
|
+
|
|
2158
|
+
raise PreventUpdate
|
|
1589
2159
|
|
|
1590
2160
|
|
|
1591
2161
|
# =============================================================================
|
|
@@ -1650,6 +2220,7 @@ def run_app(
|
|
|
1650
2220
|
debug=None,
|
|
1651
2221
|
title=None,
|
|
1652
2222
|
subtitle=None,
|
|
2223
|
+
welcome_message=None,
|
|
1653
2224
|
config_file=None
|
|
1654
2225
|
):
|
|
1655
2226
|
"""
|
|
@@ -1662,14 +2233,15 @@ def run_app(
|
|
|
1662
2233
|
agent_instance (object, optional): Agent object instance (Python API only)
|
|
1663
2234
|
workspace (str, optional): Workspace directory path
|
|
1664
2235
|
agent_spec (str, optional): Agent specification (overrides agent_instance).
|
|
1665
|
-
Supports two formats:
|
|
2236
|
+
Supports two formats (both use colon separator):
|
|
1666
2237
|
- File path: "path/to/file.py:object_name"
|
|
1667
|
-
- Module path: "mypackage.module
|
|
2238
|
+
- Module path: "mypackage.module:object_name"
|
|
1668
2239
|
port (int, optional): Port number
|
|
1669
2240
|
host (str, optional): Host to bind to
|
|
1670
2241
|
debug (bool, optional): Debug mode
|
|
1671
2242
|
title (str, optional): Application title
|
|
1672
2243
|
subtitle (str, optional): Application subtitle
|
|
2244
|
+
welcome_message (str, optional): Welcome message shown on startup (supports markdown)
|
|
1673
2245
|
config_file (str, optional): Path to config file (default: ./config.py)
|
|
1674
2246
|
|
|
1675
2247
|
Returns:
|
|
@@ -1685,12 +2257,12 @@ def run_app(
|
|
|
1685
2257
|
>>> run_app(agent_spec="my_agent.py:agent", port=8080)
|
|
1686
2258
|
|
|
1687
2259
|
>>> # Using agent spec (module format)
|
|
1688
|
-
>>> run_app(agent_spec="mypackage.agents
|
|
2260
|
+
>>> run_app(agent_spec="mypackage.agents:my_agent", port=8080)
|
|
1689
2261
|
|
|
1690
2262
|
>>> # Without agent (manual mode)
|
|
1691
2263
|
>>> run_app(workspace="~/my-workspace", debug=True)
|
|
1692
2264
|
"""
|
|
1693
|
-
global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, agent, AGENT_ERROR, args
|
|
2265
|
+
global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, WELCOME_MESSAGE, agent, AGENT_ERROR, args
|
|
1694
2266
|
|
|
1695
2267
|
# Load config file if specified and exists
|
|
1696
2268
|
config_module = None
|
|
@@ -1715,6 +2287,7 @@ def run_app(
|
|
|
1715
2287
|
PORT = port if port is not None else getattr(config_module, "PORT", config.PORT)
|
|
1716
2288
|
HOST = host if host else getattr(config_module, "HOST", config.HOST)
|
|
1717
2289
|
DEBUG = debug if debug is not None else getattr(config_module, "DEBUG", config.DEBUG)
|
|
2290
|
+
WELCOME_MESSAGE = welcome_message if welcome_message else getattr(config_module, "WELCOME_MESSAGE", config.WELCOME_MESSAGE)
|
|
1718
2291
|
|
|
1719
2292
|
# Agent priority: agent_spec > agent_instance > config file
|
|
1720
2293
|
if agent_spec:
|
|
@@ -1745,6 +2318,7 @@ def run_app(
|
|
|
1745
2318
|
PORT = port if port is not None else config.PORT
|
|
1746
2319
|
HOST = host if host else config.HOST
|
|
1747
2320
|
DEBUG = debug if debug is not None else config.DEBUG
|
|
2321
|
+
WELCOME_MESSAGE = welcome_message if welcome_message else config.WELCOME_MESSAGE
|
|
1748
2322
|
|
|
1749
2323
|
# Agent priority: agent_spec > agent_instance > config default
|
|
1750
2324
|
if agent_spec:
|