cowork-dash 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl

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