cowork-dash 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl

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