cowork-dash 0.1.4__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 +4 -4
- cowork_dash/app.py +668 -106
- 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 +115 -23
- cowork_dash/tools.py +88 -11
- {cowork_dash-0.1.4.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.4.dist-info/RECORD +0 -19
- {cowork_dash-0.1.4.dist-info → cowork_dash-0.1.5.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.4.dist-info → cowork_dash-0.1.5.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.4.dist-info → cowork_dash-0.1.5.dist-info}/licenses/LICENSE +0 -0
cowork_dash/app.py
CHANGED
|
@@ -201,6 +201,7 @@ APP_SUBTITLE = config.APP_SUBTITLE
|
|
|
201
201
|
PORT = config.PORT
|
|
202
202
|
HOST = config.HOST
|
|
203
203
|
DEBUG = config.DEBUG
|
|
204
|
+
WELCOME_MESSAGE = config.WELCOME_MESSAGE
|
|
204
205
|
|
|
205
206
|
# Ensure workspace exists
|
|
206
207
|
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
@@ -288,15 +289,24 @@ _agent_state = {
|
|
|
288
289
|
"interrupt": None, # Track interrupt requests for human-in-the-loop
|
|
289
290
|
"last_update": time.time(),
|
|
290
291
|
"start_time": None, # Track when agent started for response time calculation
|
|
292
|
+
"stop_requested": False, # Flag to request agent stop
|
|
291
293
|
}
|
|
292
294
|
_agent_state_lock = threading.Lock()
|
|
293
295
|
|
|
294
|
-
|
|
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):
|
|
295
304
|
"""Run agent in background thread and update global state in real-time.
|
|
296
305
|
|
|
297
306
|
Args:
|
|
298
307
|
message: User message to send to agent
|
|
299
308
|
resume_data: Optional dict with 'decisions' to resume from interrupt
|
|
309
|
+
workspace_path: Current workspace directory path to inject into agent context
|
|
300
310
|
"""
|
|
301
311
|
if not agent:
|
|
302
312
|
with _agent_state_lock:
|
|
@@ -345,9 +355,24 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
|
|
|
345
355
|
from langgraph.types import Command
|
|
346
356
|
agent_input = Command(resume=resume_data)
|
|
347
357
|
else:
|
|
348
|
-
|
|
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}]}
|
|
349
365
|
|
|
350
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
|
+
|
|
351
376
|
# Check for interrupt
|
|
352
377
|
if isinstance(update, dict) and "__interrupt__" in update:
|
|
353
378
|
interrupt_value = update["__interrupt__"]
|
|
@@ -477,6 +502,66 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
|
|
|
477
502
|
except Exception as e:
|
|
478
503
|
print(f"Failed to export canvas: {e}")
|
|
479
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
|
+
|
|
480
565
|
elif last_msg.name in ('execute_cell', 'execute_all_cells'):
|
|
481
566
|
# Extract canvas_items from cell execution results
|
|
482
567
|
content = last_msg.content
|
|
@@ -643,12 +728,13 @@ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
|
643
728
|
|
|
644
729
|
return interrupt_data
|
|
645
730
|
|
|
646
|
-
def call_agent(message: str, resume_data: Dict = None):
|
|
731
|
+
def call_agent(message: str, resume_data: Dict = None, workspace_path: str = None):
|
|
647
732
|
"""Start agent execution in background thread.
|
|
648
733
|
|
|
649
734
|
Args:
|
|
650
735
|
message: User message to send to agent
|
|
651
736
|
resume_data: Optional dict with decisions to resume from interrupt
|
|
737
|
+
workspace_path: Current workspace directory path to inject into agent context
|
|
652
738
|
"""
|
|
653
739
|
# Reset state but preserve canvas - do it all atomically
|
|
654
740
|
with _agent_state_lock:
|
|
@@ -666,10 +752,11 @@ def call_agent(message: str, resume_data: Dict = None):
|
|
|
666
752
|
"interrupt": None, # Clear any previous interrupt
|
|
667
753
|
"last_update": time.time(),
|
|
668
754
|
"start_time": time.time(), # Track when agent started
|
|
755
|
+
"stop_requested": False, # Reset stop flag
|
|
669
756
|
})
|
|
670
757
|
|
|
671
758
|
# Start background thread
|
|
672
|
-
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))
|
|
673
760
|
thread.daemon = True
|
|
674
761
|
thread.start()
|
|
675
762
|
|
|
@@ -788,7 +875,7 @@ app.index_string = '''<!DOCTYPE html>
|
|
|
788
875
|
<head>
|
|
789
876
|
{%metas%}
|
|
790
877
|
<title>{%title%}</title>
|
|
791
|
-
<link rel="icon" type="image/svg+xml" href="/assets/favicon.
|
|
878
|
+
<link rel="icon" type="image/svg+xml" href="/assets/favicon.ico">
|
|
792
879
|
{%css%}
|
|
793
880
|
</head>
|
|
794
881
|
<body>
|
|
@@ -818,7 +905,8 @@ def create_layout():
|
|
|
818
905
|
app_subtitle=subtitle,
|
|
819
906
|
colors=COLORS,
|
|
820
907
|
styles=STYLES,
|
|
821
|
-
agent=agent
|
|
908
|
+
agent=agent,
|
|
909
|
+
welcome_message=WELCOME_MESSAGE
|
|
822
910
|
)
|
|
823
911
|
|
|
824
912
|
# Set layout as a function so it uses current WORKSPACE_ROOT
|
|
@@ -871,10 +959,11 @@ def display_initial_messages(history, theme):
|
|
|
871
959
|
Input("chat-input", "n_submit")],
|
|
872
960
|
[State("chat-input", "value"),
|
|
873
961
|
State("chat-history", "data"),
|
|
874
|
-
State("theme-store", "data")
|
|
962
|
+
State("theme-store", "data"),
|
|
963
|
+
State("current-workspace-path", "data")],
|
|
875
964
|
prevent_initial_call=True
|
|
876
965
|
)
|
|
877
|
-
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):
|
|
878
967
|
"""Phase 1: Immediately show user message and start agent."""
|
|
879
968
|
if not message or not message.strip():
|
|
880
969
|
raise PreventUpdate
|
|
@@ -903,8 +992,11 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme):
|
|
|
903
992
|
|
|
904
993
|
messages.append(format_loading(colors))
|
|
905
994
|
|
|
906
|
-
#
|
|
907
|
-
|
|
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))
|
|
908
1000
|
|
|
909
1001
|
# Enable polling
|
|
910
1002
|
return messages, history, "", message, False
|
|
@@ -1054,6 +1146,72 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1054
1146
|
return messages, no_update, False
|
|
1055
1147
|
|
|
1056
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
|
+
|
|
1057
1215
|
# Interrupt handling callbacks
|
|
1058
1216
|
@app.callback(
|
|
1059
1217
|
[Output("chat-messages", "children", allow_duplicate=True),
|
|
@@ -1132,13 +1290,14 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1132
1290
|
return messages, False
|
|
1133
1291
|
|
|
1134
1292
|
|
|
1135
|
-
# Folder toggle callback
|
|
1293
|
+
# Folder toggle callback - triggered by clicking the expand icon
|
|
1136
1294
|
@app.callback(
|
|
1137
1295
|
[Output({"type": "folder-children", "path": ALL}, "style"),
|
|
1138
1296
|
Output({"type": "folder-icon", "path": ALL}, "style"),
|
|
1139
1297
|
Output({"type": "folder-children", "path": ALL}, "children")],
|
|
1140
|
-
Input({"type": "folder-
|
|
1141
|
-
[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"),
|
|
1142
1301
|
State({"type": "folder-children", "path": ALL}, "id"),
|
|
1143
1302
|
State({"type": "folder-icon", "path": ALL}, "id"),
|
|
1144
1303
|
State({"type": "folder-children", "path": ALL}, "style"),
|
|
@@ -1147,7 +1306,7 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1147
1306
|
State("theme-store", "data")],
|
|
1148
1307
|
prevent_initial_call=True
|
|
1149
1308
|
)
|
|
1150
|
-
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):
|
|
1151
1310
|
"""Toggle folder expansion and lazy load contents if needed."""
|
|
1152
1311
|
ctx = callback_context
|
|
1153
1312
|
if not ctx.triggered or not any(n_clicks):
|
|
@@ -1162,17 +1321,13 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1162
1321
|
except:
|
|
1163
1322
|
raise PreventUpdate
|
|
1164
1323
|
|
|
1165
|
-
#
|
|
1166
|
-
|
|
1167
|
-
for i,
|
|
1168
|
-
if
|
|
1169
|
-
|
|
1170
|
-
break
|
|
1171
|
-
|
|
1172
|
-
if clicked_idx is None:
|
|
1173
|
-
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]
|
|
1174
1329
|
|
|
1175
|
-
folder_rel_path =
|
|
1330
|
+
folder_rel_path = path_to_realpath.get(clicked_path)
|
|
1176
1331
|
if not folder_rel_path:
|
|
1177
1332
|
raise PreventUpdate
|
|
1178
1333
|
|
|
@@ -1201,7 +1356,7 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1201
1356
|
try:
|
|
1202
1357
|
folder_items = load_folder_contents(folder_rel_path, WORKSPACE_ROOT)
|
|
1203
1358
|
loaded_content = render_file_tree(folder_items, colors, STYLES,
|
|
1204
|
-
level=folder_rel_path.count("/") + 1,
|
|
1359
|
+
level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
|
|
1205
1360
|
parent_path=folder_rel_path)
|
|
1206
1361
|
new_children_content.append(loaded_content if loaded_content else current_content)
|
|
1207
1362
|
except Exception as e:
|
|
@@ -1227,11 +1382,11 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1227
1382
|
current_children_style = children_styles[children_idx] if children_idx < len(children_styles) else {"display": "none"}
|
|
1228
1383
|
is_expanded = current_children_style.get("display") != "none"
|
|
1229
1384
|
new_icon_styles.append({
|
|
1230
|
-
"marginRight": "
|
|
1385
|
+
"marginRight": "5px",
|
|
1231
1386
|
"fontSize": "10px",
|
|
1232
|
-
"
|
|
1233
|
-
"transition": "transform 0.2s",
|
|
1387
|
+
"transition": "transform 0.15s",
|
|
1234
1388
|
"display": "inline-block",
|
|
1389
|
+
"padding": "2px",
|
|
1235
1390
|
"transform": "rotate(0deg)" if is_expanded else "rotate(90deg)",
|
|
1236
1391
|
})
|
|
1237
1392
|
else:
|
|
@@ -1242,6 +1397,129 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
|
|
|
1242
1397
|
return new_children_styles, new_icon_styles, new_children_content
|
|
1243
1398
|
|
|
1244
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
|
+
|
|
1245
1523
|
# File click - open modal
|
|
1246
1524
|
@app.callback(
|
|
1247
1525
|
[Output("file-modal", "opened"),
|
|
@@ -1416,69 +1694,149 @@ def open_terminal(n_clicks):
|
|
|
1416
1694
|
[Output("file-tree", "children"),
|
|
1417
1695
|
Output("canvas-content", "children", allow_duplicate=True)],
|
|
1418
1696
|
Input("refresh-btn", "n_clicks"),
|
|
1419
|
-
[State("
|
|
1697
|
+
[State("current-workspace-path", "data"),
|
|
1698
|
+
State("theme-store", "data"),
|
|
1699
|
+
State("collapsed-canvas-items", "data")],
|
|
1420
1700
|
prevent_initial_call=True
|
|
1421
1701
|
)
|
|
1422
|
-
def refresh_sidebar(n_clicks, theme):
|
|
1702
|
+
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
|
|
1423
1703
|
"""Refresh both file tree and canvas content."""
|
|
1424
1704
|
global _agent_state
|
|
1425
1705
|
colors = get_colors(theme or "light")
|
|
1706
|
+
collapsed_ids = collapsed_ids or []
|
|
1426
1707
|
|
|
1427
|
-
#
|
|
1428
|
-
|
|
1708
|
+
# Calculate current workspace directory
|
|
1709
|
+
current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
|
|
1429
1710
|
|
|
1430
|
-
# 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)
|
|
1431
1715
|
canvas_items = load_canvas_from_markdown(WORKSPACE_ROOT)
|
|
1432
1716
|
|
|
1433
1717
|
# Update agent state with reloaded canvas
|
|
1434
1718
|
with _agent_state_lock:
|
|
1435
1719
|
_agent_state["canvas"] = canvas_items
|
|
1436
1720
|
|
|
1437
|
-
# Render the canvas items
|
|
1438
|
-
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)
|
|
1439
1723
|
|
|
1440
1724
|
return file_tree, canvas_content
|
|
1441
1725
|
|
|
1442
1726
|
|
|
1443
|
-
# File upload
|
|
1727
|
+
# File upload (sidebar button) - uploads to current workspace directory
|
|
1444
1728
|
@app.callback(
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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"),
|
|
1449
1733
|
State("theme-store", "data")],
|
|
1450
1734
|
prevent_initial_call=True
|
|
1451
1735
|
)
|
|
1452
|
-
def
|
|
1453
|
-
"""Handle file uploads."""
|
|
1736
|
+
def handle_sidebar_upload(contents, filenames, current_workspace, theme):
|
|
1737
|
+
"""Handle file uploads from sidebar button to current workspace."""
|
|
1454
1738
|
if not contents:
|
|
1455
1739
|
raise PreventUpdate
|
|
1456
1740
|
|
|
1457
1741
|
colors = get_colors(theme or "light")
|
|
1458
|
-
|
|
1742
|
+
# Calculate current workspace directory
|
|
1743
|
+
current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
|
|
1744
|
+
|
|
1459
1745
|
for content, filename in zip(contents, filenames):
|
|
1460
1746
|
try:
|
|
1461
1747
|
_, content_string = content.split(',')
|
|
1462
1748
|
decoded = base64.b64decode(content_string)
|
|
1463
|
-
file_path =
|
|
1749
|
+
file_path = current_workspace_dir / filename
|
|
1464
1750
|
try:
|
|
1465
1751
|
file_path.write_text(decoded.decode('utf-8'))
|
|
1466
1752
|
except UnicodeDecodeError:
|
|
1467
1753
|
file_path.write_bytes(decoded)
|
|
1468
|
-
uploaded.append(filename)
|
|
1469
1754
|
except Exception as e:
|
|
1470
1755
|
print(f"Upload error: {e}")
|
|
1471
1756
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
|
1475
1831
|
|
|
1476
1832
|
|
|
1477
1833
|
# View toggle callbacks - using SegmentedControl
|
|
1478
1834
|
@app.callback(
|
|
1479
1835
|
[Output("files-view", "style"),
|
|
1480
1836
|
Output("canvas-view", "style"),
|
|
1481
|
-
Output("open-terminal-btn", "style")
|
|
1837
|
+
Output("open-terminal-btn", "style"),
|
|
1838
|
+
Output("create-folder-btn", "style"),
|
|
1839
|
+
Output("file-upload-sidebar", "style")],
|
|
1482
1840
|
[Input("sidebar-view-toggle", "value")],
|
|
1483
1841
|
prevent_initial_call=True
|
|
1484
1842
|
)
|
|
@@ -1488,7 +1846,7 @@ def toggle_view(view_value):
|
|
|
1488
1846
|
raise PreventUpdate
|
|
1489
1847
|
|
|
1490
1848
|
if view_value == "canvas":
|
|
1491
|
-
# Show canvas, hide files, hide
|
|
1849
|
+
# Show canvas, hide files, hide file-related buttons
|
|
1492
1850
|
return (
|
|
1493
1851
|
{"flex": "1", "display": "none", "flexDirection": "column"},
|
|
1494
1852
|
{
|
|
@@ -1498,10 +1856,12 @@ def toggle_view(view_value):
|
|
|
1498
1856
|
"flexDirection": "column",
|
|
1499
1857
|
"overflow": "hidden"
|
|
1500
1858
|
},
|
|
1501
|
-
{"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
|
|
1502
1862
|
)
|
|
1503
1863
|
else:
|
|
1504
|
-
# Show files, hide canvas, show
|
|
1864
|
+
# Show files, hide canvas, show file-related buttons
|
|
1505
1865
|
return (
|
|
1506
1866
|
{
|
|
1507
1867
|
"flex": "1",
|
|
@@ -1517,7 +1877,9 @@ def toggle_view(view_value):
|
|
|
1517
1877
|
"flexDirection": "column",
|
|
1518
1878
|
"overflow": "hidden"
|
|
1519
1879
|
},
|
|
1520
|
-
{} # 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)
|
|
1521
1883
|
)
|
|
1522
1884
|
|
|
1523
1885
|
|
|
@@ -1526,78 +1888,274 @@ def toggle_view(view_value):
|
|
|
1526
1888
|
Output("canvas-content", "children"),
|
|
1527
1889
|
[Input("poll-interval", "n_intervals"),
|
|
1528
1890
|
Input("sidebar-view-toggle", "value")],
|
|
1529
|
-
[State("theme-store", "data")
|
|
1891
|
+
[State("theme-store", "data"),
|
|
1892
|
+
State("collapsed-canvas-items", "data")],
|
|
1530
1893
|
prevent_initial_call=False
|
|
1531
1894
|
)
|
|
1532
|
-
def update_canvas_content(n_intervals, view_value, theme):
|
|
1895
|
+
def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
|
|
1533
1896
|
"""Update canvas content from agent state."""
|
|
1534
1897
|
state = get_agent_state()
|
|
1535
1898
|
canvas_items = state.get("canvas", [])
|
|
1536
1899
|
colors = get_colors(theme or "light")
|
|
1900
|
+
collapsed_ids = collapsed_ids or []
|
|
1537
1901
|
|
|
1538
|
-
# Use imported rendering function
|
|
1539
|
-
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)
|
|
1540
1904
|
|
|
1541
1905
|
|
|
1542
1906
|
|
|
1543
|
-
#
|
|
1907
|
+
# Open clear canvas confirmation modal
|
|
1544
1908
|
@app.callback(
|
|
1545
|
-
Output("canvas-
|
|
1909
|
+
Output("clear-canvas-modal", "opened"),
|
|
1546
1910
|
Input("clear-canvas-btn", "n_clicks"),
|
|
1547
|
-
[State("theme-store", "data")],
|
|
1548
1911
|
prevent_initial_call=True
|
|
1549
1912
|
)
|
|
1550
|
-
def
|
|
1551
|
-
"""
|
|
1913
|
+
def open_clear_canvas_modal(n_clicks):
|
|
1914
|
+
"""Open the clear canvas confirmation modal."""
|
|
1552
1915
|
if not n_clicks:
|
|
1553
1916
|
raise PreventUpdate
|
|
1917
|
+
return True
|
|
1554
1918
|
|
|
1555
|
-
global _agent_state
|
|
1556
|
-
colors = get_colors(theme or "light")
|
|
1557
1919
|
|
|
1558
|
-
|
|
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
|
|
1935
|
+
|
|
1936
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
1559
1937
|
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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}")
|
|
1938
|
+
if triggered_id == "cancel-clear-canvas-btn":
|
|
1939
|
+
# Close modal without clearing
|
|
1940
|
+
return no_update, False, no_update
|
|
1569
1941
|
|
|
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
|
-
|
|
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
|
|
1601
2159
|
|
|
1602
2160
|
|
|
1603
2161
|
# =============================================================================
|
|
@@ -1662,6 +2220,7 @@ def run_app(
|
|
|
1662
2220
|
debug=None,
|
|
1663
2221
|
title=None,
|
|
1664
2222
|
subtitle=None,
|
|
2223
|
+
welcome_message=None,
|
|
1665
2224
|
config_file=None
|
|
1666
2225
|
):
|
|
1667
2226
|
"""
|
|
@@ -1682,6 +2241,7 @@ def run_app(
|
|
|
1682
2241
|
debug (bool, optional): Debug mode
|
|
1683
2242
|
title (str, optional): Application title
|
|
1684
2243
|
subtitle (str, optional): Application subtitle
|
|
2244
|
+
welcome_message (str, optional): Welcome message shown on startup (supports markdown)
|
|
1685
2245
|
config_file (str, optional): Path to config file (default: ./config.py)
|
|
1686
2246
|
|
|
1687
2247
|
Returns:
|
|
@@ -1702,7 +2262,7 @@ def run_app(
|
|
|
1702
2262
|
>>> # Without agent (manual mode)
|
|
1703
2263
|
>>> run_app(workspace="~/my-workspace", debug=True)
|
|
1704
2264
|
"""
|
|
1705
|
-
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
|
|
1706
2266
|
|
|
1707
2267
|
# Load config file if specified and exists
|
|
1708
2268
|
config_module = None
|
|
@@ -1727,6 +2287,7 @@ def run_app(
|
|
|
1727
2287
|
PORT = port if port is not None else getattr(config_module, "PORT", config.PORT)
|
|
1728
2288
|
HOST = host if host else getattr(config_module, "HOST", config.HOST)
|
|
1729
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)
|
|
1730
2291
|
|
|
1731
2292
|
# Agent priority: agent_spec > agent_instance > config file
|
|
1732
2293
|
if agent_spec:
|
|
@@ -1757,6 +2318,7 @@ def run_app(
|
|
|
1757
2318
|
PORT = port if port is not None else config.PORT
|
|
1758
2319
|
HOST = host if host else config.HOST
|
|
1759
2320
|
DEBUG = debug if debug is not None else config.DEBUG
|
|
2321
|
+
WELCOME_MESSAGE = welcome_message if welcome_message else config.WELCOME_MESSAGE
|
|
1760
2322
|
|
|
1761
2323
|
# Agent priority: agent_spec > agent_instance > config default
|
|
1762
2324
|
if agent_spec:
|