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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cowork_dash/app.py CHANGED
@@ -201,6 +201,7 @@ APP_SUBTITLE = config.APP_SUBTITLE
201
201
  PORT = config.PORT
202
202
  HOST = config.HOST
203
203
  DEBUG = config.DEBUG
204
+ WELCOME_MESSAGE = config.WELCOME_MESSAGE
204
205
 
205
206
  # Ensure workspace exists
206
207
  WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
@@ -288,15 +289,24 @@ _agent_state = {
288
289
  "interrupt": None, # Track interrupt requests for human-in-the-loop
289
290
  "last_update": time.time(),
290
291
  "start_time": None, # Track when agent started for response time calculation
292
+ "stop_requested": False, # Flag to request agent stop
291
293
  }
292
294
  _agent_state_lock = threading.Lock()
293
295
 
294
- 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):
295
304
  """Run agent in background thread and update global state in real-time.
296
305
 
297
306
  Args:
298
307
  message: User message to send to agent
299
308
  resume_data: Optional dict with 'decisions' to resume from interrupt
309
+ workspace_path: Current workspace directory path to inject into agent context
300
310
  """
301
311
  if not agent:
302
312
  with _agent_state_lock:
@@ -345,9 +355,24 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
345
355
  from langgraph.types import Command
346
356
  agent_input = Command(resume=resume_data)
347
357
  else:
348
- 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}]}
349
365
 
350
366
  for update in agent.stream(agent_input, stream_mode="updates", config=stream_config):
367
+ # Check if stop was requested
368
+ with _agent_state_lock:
369
+ if _agent_state.get("stop_requested"):
370
+ _agent_state["response"] = _agent_state.get("response", "") + "\n\nAgent stopped by user."
371
+ _agent_state["running"] = False
372
+ _agent_state["stop_requested"] = False
373
+ _agent_state["last_update"] = time.time()
374
+ return
375
+
351
376
  # Check for interrupt
352
377
  if isinstance(update, dict) and "__interrupt__" in update:
353
378
  interrupt_value = update["__interrupt__"]
@@ -477,6 +502,66 @@ def _run_agent_stream(message: str, resume_data: Dict = None):
477
502
  except Exception as e:
478
503
  print(f"Failed to export canvas: {e}")
479
504
 
505
+ elif last_msg.name == 'update_canvas_item':
506
+ content = last_msg.content
507
+ # Parse the canvas item to update
508
+ if isinstance(content, str):
509
+ try:
510
+ canvas_item = json.loads(content)
511
+ except:
512
+ canvas_item = {"type": "markdown", "data": content}
513
+ elif isinstance(content, dict):
514
+ canvas_item = content
515
+ else:
516
+ canvas_item = {"type": "markdown", "data": str(content)}
517
+
518
+ item_id = canvas_item.get("id")
519
+ if item_id:
520
+ with _agent_state_lock:
521
+ # Find and replace the item with matching ID
522
+ for i, existing in enumerate(_agent_state["canvas"]):
523
+ if existing.get("id") == item_id:
524
+ _agent_state["canvas"][i] = canvas_item
525
+ break
526
+ else:
527
+ # If not found, append as new item
528
+ _agent_state["canvas"].append(canvas_item)
529
+ _agent_state["last_update"] = time.time()
530
+
531
+ # Export to markdown file
532
+ try:
533
+ export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
534
+ except Exception as e:
535
+ print(f"Failed to export canvas: {e}")
536
+
537
+ elif last_msg.name == 'remove_canvas_item':
538
+ content = last_msg.content
539
+ # Parse to get the item ID to remove
540
+ if isinstance(content, str):
541
+ try:
542
+ parsed = json.loads(content)
543
+ item_id = parsed.get("id")
544
+ except:
545
+ item_id = content # Assume string is the ID
546
+ elif isinstance(content, dict):
547
+ item_id = content.get("id")
548
+ else:
549
+ item_id = None
550
+
551
+ if item_id:
552
+ with _agent_state_lock:
553
+ _agent_state["canvas"] = [
554
+ item for item in _agent_state["canvas"]
555
+ if item.get("id") != item_id
556
+ ]
557
+ _agent_state["last_update"] = time.time()
558
+
559
+ # Export to markdown file
560
+ try:
561
+ export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
562
+ except Exception as e:
563
+ print(f"Failed to export canvas: {e}")
564
+
480
565
  elif last_msg.name in ('execute_cell', 'execute_all_cells'):
481
566
  # Extract canvas_items from cell execution results
482
567
  content = last_msg.content
@@ -643,12 +728,13 @@ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
643
728
 
644
729
  return interrupt_data
645
730
 
646
- def call_agent(message: str, resume_data: Dict = None):
731
+ def call_agent(message: str, resume_data: Dict = None, workspace_path: str = None):
647
732
  """Start agent execution in background thread.
648
733
 
649
734
  Args:
650
735
  message: User message to send to agent
651
736
  resume_data: Optional dict with decisions to resume from interrupt
737
+ workspace_path: Current workspace directory path to inject into agent context
652
738
  """
653
739
  # Reset state but preserve canvas - do it all atomically
654
740
  with _agent_state_lock:
@@ -666,10 +752,11 @@ def call_agent(message: str, resume_data: Dict = None):
666
752
  "interrupt": None, # Clear any previous interrupt
667
753
  "last_update": time.time(),
668
754
  "start_time": time.time(), # Track when agent started
755
+ "stop_requested": False, # Reset stop flag
669
756
  })
670
757
 
671
758
  # Start background thread
672
- thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data))
759
+ thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data, workspace_path))
673
760
  thread.daemon = True
674
761
  thread.start()
675
762
 
@@ -788,7 +875,7 @@ app.index_string = '''<!DOCTYPE html>
788
875
  <head>
789
876
  {%metas%}
790
877
  <title>{%title%}</title>
791
- <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
878
+ <link rel="icon" type="image/svg+xml" href="/assets/favicon.ico">
792
879
  {%css%}
793
880
  </head>
794
881
  <body>
@@ -818,7 +905,8 @@ def create_layout():
818
905
  app_subtitle=subtitle,
819
906
  colors=COLORS,
820
907
  styles=STYLES,
821
- agent=agent
908
+ agent=agent,
909
+ welcome_message=WELCOME_MESSAGE
822
910
  )
823
911
 
824
912
  # Set layout as a function so it uses current WORKSPACE_ROOT
@@ -871,10 +959,11 @@ def display_initial_messages(history, theme):
871
959
  Input("chat-input", "n_submit")],
872
960
  [State("chat-input", "value"),
873
961
  State("chat-history", "data"),
874
- State("theme-store", "data")],
962
+ State("theme-store", "data"),
963
+ State("current-workspace-path", "data")],
875
964
  prevent_initial_call=True
876
965
  )
877
- def handle_send_immediate(n_clicks, n_submit, message, history, theme):
966
+ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_workspace_path):
878
967
  """Phase 1: Immediately show user message and start agent."""
879
968
  if not message or not message.strip():
880
969
  raise PreventUpdate
@@ -903,8 +992,11 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme):
903
992
 
904
993
  messages.append(format_loading(colors))
905
994
 
906
- # Start agent in background
907
- 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))
908
1000
 
909
1001
  # Enable polling
910
1002
  return messages, history, "", message, False
@@ -1054,6 +1146,72 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
1054
1146
  return messages, no_update, False
1055
1147
 
1056
1148
 
1149
+ # Stop button visibility - show when agent is running
1150
+ @app.callback(
1151
+ Output("stop-btn", "style"),
1152
+ Input("poll-interval", "n_intervals"),
1153
+ prevent_initial_call=True
1154
+ )
1155
+ def update_stop_button_visibility(n_intervals):
1156
+ """Show stop button when agent is running, hide otherwise."""
1157
+ state = get_agent_state()
1158
+ if state.get("running"):
1159
+ return {} # Show button (remove display:none)
1160
+ else:
1161
+ return {"display": "none"} # Hide button
1162
+
1163
+
1164
+ # Stop button click handler
1165
+ @app.callback(
1166
+ [Output("chat-messages", "children", allow_duplicate=True),
1167
+ Output("poll-interval", "disabled", allow_duplicate=True)],
1168
+ Input("stop-btn", "n_clicks"),
1169
+ [State("chat-history", "data"),
1170
+ State("theme-store", "data")],
1171
+ prevent_initial_call=True
1172
+ )
1173
+ def handle_stop_button(n_clicks, history, theme):
1174
+ """Handle stop button click to stop agent execution."""
1175
+ if not n_clicks:
1176
+ raise PreventUpdate
1177
+
1178
+ colors = get_colors(theme or "light")
1179
+ history = history or []
1180
+
1181
+ # Request the agent to stop
1182
+ request_agent_stop()
1183
+
1184
+ # Render current messages with a stopping indicator
1185
+ def render_history_messages(history):
1186
+ messages = []
1187
+ for i, msg in enumerate(history):
1188
+ msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1189
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
1190
+ if msg.get("tool_calls"):
1191
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1192
+ if tool_calls_block:
1193
+ messages.append(tool_calls_block)
1194
+ if msg.get("todos"):
1195
+ todos_block = format_todos_inline(msg["todos"], colors)
1196
+ if todos_block:
1197
+ messages.append(todos_block)
1198
+ return messages
1199
+
1200
+ messages = render_history_messages(history)
1201
+
1202
+ # Add stopping message
1203
+ messages.append(html.Div([
1204
+ html.Span("Stopping...", style={
1205
+ "fontSize": "15px",
1206
+ "fontWeight": "500",
1207
+ "color": colors["warning"],
1208
+ })
1209
+ ], className="chat-message chat-message-loading", style={"padding": "12px 15px"}))
1210
+
1211
+ # Keep polling to detect when agent actually stops
1212
+ return messages, False
1213
+
1214
+
1057
1215
  # Interrupt handling callbacks
1058
1216
  @app.callback(
1059
1217
  [Output("chat-messages", "children", allow_duplicate=True),
@@ -1132,13 +1290,14 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1132
1290
  return messages, False
1133
1291
 
1134
1292
 
1135
- # Folder toggle callback
1293
+ # Folder toggle callback - triggered by clicking the expand icon
1136
1294
  @app.callback(
1137
1295
  [Output({"type": "folder-children", "path": ALL}, "style"),
1138
1296
  Output({"type": "folder-icon", "path": ALL}, "style"),
1139
1297
  Output({"type": "folder-children", "path": ALL}, "children")],
1140
- Input({"type": "folder-header", "path": ALL}, "n_clicks"),
1141
- [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"),
1142
1301
  State({"type": "folder-children", "path": ALL}, "id"),
1143
1302
  State({"type": "folder-icon", "path": ALL}, "id"),
1144
1303
  State({"type": "folder-children", "path": ALL}, "style"),
@@ -1147,7 +1306,7 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1147
1306
  State("theme-store", "data")],
1148
1307
  prevent_initial_call=True
1149
1308
  )
1150
- def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
1309
+ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
1151
1310
  """Toggle folder expansion and lazy load contents if needed."""
1152
1311
  ctx = callback_context
1153
1312
  if not ctx.triggered or not any(n_clicks):
@@ -1162,17 +1321,13 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
1162
1321
  except:
1163
1322
  raise PreventUpdate
1164
1323
 
1165
- # 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
1171
-
1172
- if clicked_idx is None:
1173
- raise PreventUpdate
1324
+ # Build a mapping from folder path to real path using header_ids and real_paths
1325
+ path_to_realpath = {}
1326
+ for i, header_id in enumerate(header_ids):
1327
+ if i < len(real_paths):
1328
+ path_to_realpath[header_id["path"]] = real_paths[i]
1174
1329
 
1175
- folder_rel_path = real_paths[clicked_idx] if clicked_idx < len(real_paths) else None
1330
+ folder_rel_path = path_to_realpath.get(clicked_path)
1176
1331
  if not folder_rel_path:
1177
1332
  raise PreventUpdate
1178
1333
 
@@ -1201,7 +1356,7 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
1201
1356
  try:
1202
1357
  folder_items = load_folder_contents(folder_rel_path, WORKSPACE_ROOT)
1203
1358
  loaded_content = render_file_tree(folder_items, colors, STYLES,
1204
- level=folder_rel_path.count("/") + 1,
1359
+ level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
1205
1360
  parent_path=folder_rel_path)
1206
1361
  new_children_content.append(loaded_content if loaded_content else current_content)
1207
1362
  except Exception as e:
@@ -1227,11 +1382,11 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
1227
1382
  current_children_style = children_styles[children_idx] if children_idx < len(children_styles) else {"display": "none"}
1228
1383
  is_expanded = current_children_style.get("display") != "none"
1229
1384
  new_icon_styles.append({
1230
- "marginRight": "8px",
1385
+ "marginRight": "5px",
1231
1386
  "fontSize": "10px",
1232
- "color": colors["text_muted"],
1233
- "transition": "transform 0.2s",
1387
+ "transition": "transform 0.15s",
1234
1388
  "display": "inline-block",
1389
+ "padding": "2px",
1235
1390
  "transform": "rotate(0deg)" if is_expanded else "rotate(90deg)",
1236
1391
  })
1237
1392
  else:
@@ -1242,6 +1397,129 @@ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles,
1242
1397
  return new_children_styles, new_icon_styles, new_children_content
1243
1398
 
1244
1399
 
1400
+ # Enter folder callback - triggered by double-clicking folder name (changes workspace root)
1401
+ @app.callback(
1402
+ [Output("current-workspace-path", "data"),
1403
+ Output("workspace-breadcrumb", "children"),
1404
+ Output("file-tree", "children", allow_duplicate=True)],
1405
+ [Input({"type": "folder-select", "path": ALL}, "n_clicks"),
1406
+ Input("breadcrumb-root", "n_clicks"),
1407
+ Input({"type": "breadcrumb-segment", "index": ALL}, "n_clicks")],
1408
+ [State({"type": "folder-select", "path": ALL}, "id"),
1409
+ State({"type": "folder-select", "path": ALL}, "data-folderpath"),
1410
+ State({"type": "folder-select", "path": ALL}, "n_clicks"),
1411
+ State("current-workspace-path", "data"),
1412
+ State("theme-store", "data")],
1413
+ prevent_initial_call=True
1414
+ )
1415
+ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, folder_paths, prev_clicks, current_path, theme):
1416
+ """Enter a folder (double-click) or navigate via breadcrumb."""
1417
+ ctx = callback_context
1418
+ if not ctx.triggered:
1419
+ raise PreventUpdate
1420
+
1421
+ colors = get_colors(theme or "light")
1422
+ triggered = ctx.triggered[0]["prop_id"]
1423
+
1424
+ new_path = current_path or ""
1425
+
1426
+ # Check if breadcrumb root was clicked
1427
+ if "breadcrumb-root" in triggered:
1428
+ new_path = ""
1429
+ # Check if a breadcrumb segment was clicked
1430
+ elif "breadcrumb-segment" in triggered:
1431
+ try:
1432
+ id_str = triggered.rsplit(".", 1)[0]
1433
+ id_dict = json.loads(id_str)
1434
+ segment_index = id_dict.get("index")
1435
+ # Build path up to this segment
1436
+ if current_path:
1437
+ parts = current_path.split("/")
1438
+ new_path = "/".join(parts[:segment_index + 1])
1439
+ else:
1440
+ new_path = ""
1441
+ except:
1442
+ raise PreventUpdate
1443
+ # Check if a folder was double-clicked (n_clicks >= 2 and increased by 1)
1444
+ elif "folder-select" in triggered:
1445
+ try:
1446
+ id_str = triggered.rsplit(".", 1)[0]
1447
+ id_dict = json.loads(id_str)
1448
+ clicked_path = id_dict.get("path")
1449
+ except:
1450
+ raise PreventUpdate
1451
+
1452
+ # Find the folder and check for double-click
1453
+ for i, folder_id in enumerate(folder_ids):
1454
+ if folder_id["path"] == clicked_path:
1455
+ current_clicks = folder_clicks[i] if i < len(folder_clicks) else 0
1456
+ previous_clicks = prev_clicks[i] if i < len(prev_clicks) else 0
1457
+
1458
+ # Only enter on double-click (clicks increased and is even number >= 2)
1459
+ if current_clicks and current_clicks >= 2 and current_clicks % 2 == 0:
1460
+ folder_rel_path = folder_paths[i] if i < len(folder_paths) else ""
1461
+ # Combine with current workspace path
1462
+ if current_path:
1463
+ new_path = f"{current_path}/{folder_rel_path}"
1464
+ else:
1465
+ new_path = folder_rel_path
1466
+ else:
1467
+ # Single click - don't change workspace
1468
+ raise PreventUpdate
1469
+ break
1470
+ else:
1471
+ raise PreventUpdate
1472
+ else:
1473
+ raise PreventUpdate
1474
+
1475
+ # Build breadcrumb navigation
1476
+ breadcrumb_children = [
1477
+ html.Span([
1478
+ DashIconify(icon="mdi:home", width=14, style={"marginRight": "4px"}),
1479
+ "root"
1480
+ ], id="breadcrumb-root", className="breadcrumb-item breadcrumb-clickable", style={
1481
+ "display": "inline-flex",
1482
+ "alignItems": "center",
1483
+ "cursor": "pointer",
1484
+ "padding": "2px 6px",
1485
+ "borderRadius": "3px",
1486
+ }),
1487
+ ]
1488
+
1489
+ if new_path:
1490
+ parts = new_path.split("/")
1491
+ for i, part in enumerate(parts):
1492
+ # Add separator
1493
+ breadcrumb_children.append(
1494
+ html.Span(" / ", className="breadcrumb-separator", style={
1495
+ "color": "var(--mantine-color-dimmed)",
1496
+ "margin": "0 2px",
1497
+ })
1498
+ )
1499
+ # Add clickable segment
1500
+ breadcrumb_children.append(
1501
+ html.Span(part, id={"type": "breadcrumb-segment", "index": i},
1502
+ className="breadcrumb-item breadcrumb-clickable",
1503
+ style={
1504
+ "cursor": "pointer",
1505
+ "padding": "2px 6px",
1506
+ "borderRadius": "3px",
1507
+ }
1508
+ )
1509
+ )
1510
+
1511
+ # Calculate the actual workspace path
1512
+ workspace_full_path = WORKSPACE_ROOT / new_path if new_path else WORKSPACE_ROOT
1513
+
1514
+ # Render new file tree
1515
+ file_tree = render_file_tree(
1516
+ build_file_tree(workspace_full_path, workspace_full_path),
1517
+ colors, STYLES
1518
+ )
1519
+
1520
+ return new_path, breadcrumb_children, file_tree
1521
+
1522
+
1245
1523
  # File click - open modal
1246
1524
  @app.callback(
1247
1525
  [Output("file-modal", "opened"),
@@ -1416,69 +1694,149 @@ def open_terminal(n_clicks):
1416
1694
  [Output("file-tree", "children"),
1417
1695
  Output("canvas-content", "children", allow_duplicate=True)],
1418
1696
  Input("refresh-btn", "n_clicks"),
1419
- [State("theme-store", "data")],
1697
+ [State("current-workspace-path", "data"),
1698
+ State("theme-store", "data"),
1699
+ State("collapsed-canvas-items", "data")],
1420
1700
  prevent_initial_call=True
1421
1701
  )
1422
- def refresh_sidebar(n_clicks, theme):
1702
+ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
1423
1703
  """Refresh both file tree and canvas content."""
1424
1704
  global _agent_state
1425
1705
  colors = get_colors(theme or "light")
1706
+ collapsed_ids = collapsed_ids or []
1426
1707
 
1427
- # Refresh file tree
1428
- 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
1429
1710
 
1430
- # 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)
1431
1715
  canvas_items = load_canvas_from_markdown(WORKSPACE_ROOT)
1432
1716
 
1433
1717
  # Update agent state with reloaded canvas
1434
1718
  with _agent_state_lock:
1435
1719
  _agent_state["canvas"] = canvas_items
1436
1720
 
1437
- # Render the canvas items
1438
- canvas_content = render_canvas_items(canvas_items, colors)
1721
+ # Render the canvas items with preserved collapsed state
1722
+ canvas_content = render_canvas_items(canvas_items, colors, collapsed_ids)
1439
1723
 
1440
1724
  return file_tree, canvas_content
1441
1725
 
1442
1726
 
1443
- # File upload
1727
+ # File upload (sidebar button) - uploads to current workspace directory
1444
1728
  @app.callback(
1445
- [Output("upload-status", "children"),
1446
- Output("file-tree", "children", allow_duplicate=True)],
1447
- Input("file-upload", "contents"),
1448
- [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"),
1449
1733
  State("theme-store", "data")],
1450
1734
  prevent_initial_call=True
1451
1735
  )
1452
- def handle_upload(contents, filenames, theme):
1453
- """Handle file uploads."""
1736
+ def handle_sidebar_upload(contents, filenames, current_workspace, theme):
1737
+ """Handle file uploads from sidebar button to current workspace."""
1454
1738
  if not contents:
1455
1739
  raise PreventUpdate
1456
1740
 
1457
1741
  colors = get_colors(theme or "light")
1458
- uploaded = []
1742
+ # Calculate current workspace directory
1743
+ current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
1744
+
1459
1745
  for content, filename in zip(contents, filenames):
1460
1746
  try:
1461
1747
  _, content_string = content.split(',')
1462
1748
  decoded = base64.b64decode(content_string)
1463
- file_path = WORKSPACE_ROOT / filename
1749
+ file_path = current_workspace_dir / filename
1464
1750
  try:
1465
1751
  file_path.write_text(decoded.decode('utf-8'))
1466
1752
  except UnicodeDecodeError:
1467
1753
  file_path.write_bytes(decoded)
1468
- uploaded.append(filename)
1469
1754
  except Exception as e:
1470
1755
  print(f"Upload error: {e}")
1471
1756
 
1472
- 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
1757
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
1758
+
1759
+
1760
+ # Create folder modal - open
1761
+ @app.callback(
1762
+ Output("create-folder-modal", "opened"),
1763
+ [Input("create-folder-btn", "n_clicks"),
1764
+ Input("cancel-folder-btn", "n_clicks"),
1765
+ Input("confirm-folder-btn", "n_clicks")],
1766
+ [State("create-folder-modal", "opened"),
1767
+ State("new-folder-name", "value")],
1768
+ prevent_initial_call=True
1769
+ )
1770
+ def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_open, folder_name):
1771
+ """Open/close the create folder modal."""
1772
+ ctx = callback_context
1773
+ if not ctx.triggered:
1774
+ raise PreventUpdate
1775
+
1776
+ trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
1777
+
1778
+ if trigger_id == "create-folder-btn":
1779
+ return True
1780
+ elif trigger_id == "cancel-folder-btn":
1781
+ return False
1782
+ elif trigger_id == "confirm-folder-btn":
1783
+ # Close modal only if folder name is provided
1784
+ if folder_name and folder_name.strip():
1785
+ return False
1786
+ return True # Keep open if no name provided
1787
+
1788
+ return is_open
1789
+
1790
+
1791
+ # Create folder - action
1792
+ @app.callback(
1793
+ [Output("file-tree", "children", allow_duplicate=True),
1794
+ Output("create-folder-error", "children"),
1795
+ Output("new-folder-name", "value")],
1796
+ Input("confirm-folder-btn", "n_clicks"),
1797
+ [State("new-folder-name", "value"),
1798
+ State("current-workspace-path", "data"),
1799
+ State("theme-store", "data")],
1800
+ prevent_initial_call=True
1801
+ )
1802
+ def create_folder(n_clicks, folder_name, current_workspace, theme):
1803
+ """Create a new folder in the current workspace directory."""
1804
+ if not n_clicks:
1805
+ raise PreventUpdate
1806
+
1807
+ colors = get_colors(theme or "light")
1808
+
1809
+ if not folder_name or not folder_name.strip():
1810
+ return no_update, "Please enter a folder name", no_update
1811
+
1812
+ folder_name = folder_name.strip()
1813
+
1814
+ # Validate folder name
1815
+ invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
1816
+ if any(char in folder_name for char in invalid_chars):
1817
+ return no_update, f"Folder name cannot contain: {' '.join(invalid_chars)}", no_update
1818
+
1819
+ # Calculate current workspace directory
1820
+ current_workspace_dir = WORKSPACE_ROOT / current_workspace if current_workspace else WORKSPACE_ROOT
1821
+ folder_path = current_workspace_dir / folder_name
1822
+
1823
+ if folder_path.exists():
1824
+ return no_update, f"Folder '{folder_name}' already exists", no_update
1825
+
1826
+ try:
1827
+ folder_path.mkdir(parents=True, exist_ok=False)
1828
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES), "", ""
1829
+ except Exception as e:
1830
+ return no_update, f"Error creating folder: {e}", no_update
1475
1831
 
1476
1832
 
1477
1833
  # View toggle callbacks - using SegmentedControl
1478
1834
  @app.callback(
1479
1835
  [Output("files-view", "style"),
1480
1836
  Output("canvas-view", "style"),
1481
- Output("open-terminal-btn", "style")],
1837
+ Output("open-terminal-btn", "style"),
1838
+ Output("create-folder-btn", "style"),
1839
+ Output("file-upload-sidebar", "style")],
1482
1840
  [Input("sidebar-view-toggle", "value")],
1483
1841
  prevent_initial_call=True
1484
1842
  )
@@ -1488,7 +1846,7 @@ def toggle_view(view_value):
1488
1846
  raise PreventUpdate
1489
1847
 
1490
1848
  if view_value == "canvas":
1491
- # Show canvas, hide files, hide terminal button (not relevant for canvas)
1849
+ # Show canvas, hide files, hide file-related buttons
1492
1850
  return (
1493
1851
  {"flex": "1", "display": "none", "flexDirection": "column"},
1494
1852
  {
@@ -1498,10 +1856,12 @@ def toggle_view(view_value):
1498
1856
  "flexDirection": "column",
1499
1857
  "overflow": "hidden"
1500
1858
  },
1501
- {"display": "none"} # Hide terminal button on canvas view
1859
+ {"display": "none"}, # Hide terminal button
1860
+ {"display": "none"}, # Hide create folder button
1861
+ {"display": "none"}, # Hide file upload button
1502
1862
  )
1503
1863
  else:
1504
- # Show files, hide canvas, show terminal button
1864
+ # Show files, hide canvas, show file-related buttons
1505
1865
  return (
1506
1866
  {
1507
1867
  "flex": "1",
@@ -1517,7 +1877,9 @@ def toggle_view(view_value):
1517
1877
  "flexDirection": "column",
1518
1878
  "overflow": "hidden"
1519
1879
  },
1520
- {} # Show terminal button (default styles)
1880
+ {}, # Show terminal button (default styles)
1881
+ {}, # Show create folder button (default styles)
1882
+ {}, # Show file upload button (default styles)
1521
1883
  )
1522
1884
 
1523
1885
 
@@ -1526,78 +1888,274 @@ def toggle_view(view_value):
1526
1888
  Output("canvas-content", "children"),
1527
1889
  [Input("poll-interval", "n_intervals"),
1528
1890
  Input("sidebar-view-toggle", "value")],
1529
- [State("theme-store", "data")],
1891
+ [State("theme-store", "data"),
1892
+ State("collapsed-canvas-items", "data")],
1530
1893
  prevent_initial_call=False
1531
1894
  )
1532
- def update_canvas_content(n_intervals, view_value, theme):
1895
+ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
1533
1896
  """Update canvas content from agent state."""
1534
1897
  state = get_agent_state()
1535
1898
  canvas_items = state.get("canvas", [])
1536
1899
  colors = get_colors(theme or "light")
1900
+ collapsed_ids = collapsed_ids or []
1537
1901
 
1538
- # Use imported rendering function
1539
- return render_canvas_items(canvas_items, colors)
1902
+ # Use imported rendering function with preserved collapsed state
1903
+ return render_canvas_items(canvas_items, colors, collapsed_ids)
1540
1904
 
1541
1905
 
1542
1906
 
1543
- # Clear canvas callback
1907
+ # Open clear canvas confirmation modal
1544
1908
  @app.callback(
1545
- Output("canvas-content", "children", allow_duplicate=True),
1909
+ Output("clear-canvas-modal", "opened"),
1546
1910
  Input("clear-canvas-btn", "n_clicks"),
1547
- [State("theme-store", "data")],
1548
1911
  prevent_initial_call=True
1549
1912
  )
1550
- def clear_canvas(n_clicks, theme):
1551
- """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."""
1552
1915
  if not n_clicks:
1553
1916
  raise PreventUpdate
1917
+ return True
1554
1918
 
1555
- global _agent_state
1556
- colors = get_colors(theme or "light")
1557
1919
 
1558
- 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
1935
+
1936
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
1559
1937
 
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}")
1938
+ if triggered_id == "cancel-clear-canvas-btn":
1939
+ # Close modal without clearing
1940
+ return no_update, False, no_update
1569
1941
 
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
- })
1942
+ if triggered_id == "confirm-clear-canvas-btn":
1943
+ if not confirm_clicks:
1944
+ raise PreventUpdate
1945
+
1946
+ global _agent_state
1947
+ colors = get_colors(theme or "light")
1948
+
1949
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1950
+
1951
+ # Archive .canvas folder if it exists (contains canvas.md and all assets)
1952
+ canvas_dir = WORKSPACE_ROOT / ".canvas"
1953
+ if canvas_dir.exists() and canvas_dir.is_dir():
1954
+ try:
1955
+ archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
1956
+ shutil.move(str(canvas_dir), str(archive_dir))
1957
+ print(f"Archived .canvas folder to {archive_dir}")
1958
+ except Exception as e:
1959
+ print(f"Failed to archive .canvas folder: {e}")
1960
+
1961
+ # Clear canvas in state
1962
+ with _agent_state_lock:
1963
+ _agent_state["canvas"] = []
1964
+
1965
+ # Return empty state, close modal, and clear collapsed items
1966
+ return html.Div([
1967
+ html.Div("🗒", style={
1968
+ "fontSize": "48px",
1969
+ "textAlign": "center",
1970
+ "marginBottom": "16px",
1971
+ "opacity": "0.3"
1972
+ }),
1973
+ html.P("Canvas is empty", style={
1974
+ "textAlign": "center",
1975
+ "color": colors["text_muted"],
1976
+ "fontSize": "14px"
1977
+ }),
1978
+ html.P("The agent will add visualizations, charts, and notes here", style={
1979
+ "textAlign": "center",
1980
+ "color": colors["text_muted"],
1981
+ "fontSize": "12px",
1982
+ "marginTop": "8px"
1983
+ })
1984
+ ], style={
1985
+ "display": "flex",
1986
+ "flexDirection": "column",
1987
+ "alignItems": "center",
1988
+ "justifyContent": "center",
1989
+ "height": "100%",
1990
+ "padding": "40px"
1991
+ }), False, []
1992
+
1993
+ raise PreventUpdate
1994
+
1995
+
1996
+ # Collapse/expand canvas item callback
1997
+ @app.callback(
1998
+ [Output({"type": "canvas-item-content", "index": ALL}, "style"),
1999
+ Output({"type": "canvas-collapse-btn", "index": ALL}, "children"),
2000
+ Output("collapsed-canvas-items", "data")],
2001
+ Input({"type": "canvas-collapse-btn", "index": ALL}, "n_clicks"),
2002
+ [State({"type": "canvas-collapse-btn", "index": ALL}, "id"),
2003
+ State({"type": "canvas-item-content", "index": ALL}, "style"),
2004
+ State({"type": "canvas-item-content", "index": ALL}, "id"),
2005
+ State("collapsed-canvas-items", "data")],
2006
+ prevent_initial_call=True
2007
+ )
2008
+ def toggle_canvas_item_collapse(all_clicks, btn_ids, content_styles, content_ids, collapsed_ids):
2009
+ """Toggle collapse/expand state of a canvas item."""
2010
+ ctx = callback_context
2011
+ if not ctx.triggered:
2012
+ raise PreventUpdate
2013
+
2014
+ # Find which button was clicked
2015
+ triggered = ctx.triggered[0]
2016
+ triggered_id = triggered["prop_id"]
2017
+ triggered_value = triggered.get("value")
2018
+
2019
+ if not triggered_value or triggered_value <= 0:
2020
+ raise PreventUpdate
2021
+
2022
+ try:
2023
+ id_str = triggered_id.rsplit(".", 1)[0]
2024
+ id_dict = json.loads(id_str)
2025
+ clicked_item_id = id_dict.get("index")
2026
+ except:
2027
+ raise PreventUpdate
2028
+
2029
+ if not clicked_item_id:
2030
+ raise PreventUpdate
2031
+
2032
+ # Initialize collapsed_ids if None
2033
+ collapsed_ids = collapsed_ids or []
2034
+
2035
+ # Build new styles and icons for all items
2036
+ new_styles = []
2037
+ new_icons = []
2038
+ new_collapsed_ids = collapsed_ids.copy()
2039
+
2040
+ for i, content_id in enumerate(content_ids):
2041
+ item_id = content_id.get("index")
2042
+ current_style = content_styles[i] if i < len(content_styles) else {"display": "block"}
2043
+
2044
+ if item_id == clicked_item_id:
2045
+ # Toggle this item
2046
+ is_collapsed = current_style.get("display") == "none"
2047
+ new_styles.append({"display": "block"} if is_collapsed else {"display": "none"})
2048
+ # Change icon based on new state
2049
+ new_icons.append(
2050
+ DashIconify(icon="mdi:chevron-down" if is_collapsed else "mdi:chevron-right", width=16)
2051
+ )
2052
+ # Update collapsed_ids list
2053
+ if is_collapsed:
2054
+ # Was collapsed, now expanding - remove from list
2055
+ if item_id in new_collapsed_ids:
2056
+ new_collapsed_ids.remove(item_id)
2057
+ else:
2058
+ # Was expanded, now collapsing - add to list
2059
+ if item_id not in new_collapsed_ids:
2060
+ new_collapsed_ids.append(item_id)
2061
+ else:
2062
+ new_styles.append(current_style)
2063
+ # Keep existing icon state
2064
+ is_collapsed = current_style.get("display") == "none"
2065
+ new_icons.append(
2066
+ DashIconify(icon="mdi:chevron-right" if is_collapsed else "mdi:chevron-down", width=16)
2067
+ )
2068
+
2069
+ return new_styles, new_icons, new_collapsed_ids
2070
+
2071
+
2072
+ # Open delete confirmation modal
2073
+ @app.callback(
2074
+ [Output("delete-canvas-item-modal", "opened"),
2075
+ Output("delete-canvas-item-id", "data")],
2076
+ Input({"type": "canvas-delete-btn", "index": ALL}, "n_clicks"),
2077
+ [State({"type": "canvas-delete-btn", "index": ALL}, "id")],
2078
+ prevent_initial_call=True
2079
+ )
2080
+ def open_delete_confirmation(all_clicks, all_ids):
2081
+ """Open the delete confirmation modal when delete button is clicked."""
2082
+ ctx = callback_context
2083
+ if not ctx.triggered:
2084
+ raise PreventUpdate
2085
+
2086
+ triggered = ctx.triggered[0]
2087
+ triggered_id = triggered["prop_id"]
2088
+ triggered_value = triggered.get("value")
2089
+
2090
+ if not triggered_value or triggered_value <= 0:
2091
+ raise PreventUpdate
2092
+
2093
+ try:
2094
+ id_str = triggered_id.rsplit(".", 1)[0]
2095
+ id_dict = json.loads(id_str)
2096
+ item_id_to_delete = id_dict.get("index")
2097
+ except:
2098
+ raise PreventUpdate
2099
+
2100
+ if not item_id_to_delete:
2101
+ raise PreventUpdate
2102
+
2103
+ return True, item_id_to_delete
2104
+
2105
+
2106
+ # Handle delete confirmation modal actions
2107
+ @app.callback(
2108
+ [Output("canvas-content", "children", allow_duplicate=True),
2109
+ Output("delete-canvas-item-modal", "opened", allow_duplicate=True),
2110
+ Output("collapsed-canvas-items", "data", allow_duplicate=True)],
2111
+ [Input("confirm-delete-canvas-btn", "n_clicks"),
2112
+ Input("cancel-delete-canvas-btn", "n_clicks")],
2113
+ [State("delete-canvas-item-id", "data"),
2114
+ State("theme-store", "data"),
2115
+ State("collapsed-canvas-items", "data")],
2116
+ prevent_initial_call=True
2117
+ )
2118
+ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, collapsed_ids):
2119
+ """Handle the delete confirmation - either delete or cancel."""
2120
+ ctx = callback_context
2121
+ if not ctx.triggered:
2122
+ raise PreventUpdate
2123
+
2124
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2125
+
2126
+ if triggered_id == "cancel-delete-canvas-btn":
2127
+ # Close modal without deleting
2128
+ return no_update, False, no_update
2129
+
2130
+ if triggered_id == "confirm-delete-canvas-btn":
2131
+ if not confirm_clicks or not item_id:
2132
+ raise PreventUpdate
2133
+
2134
+ global _agent_state
2135
+ colors = get_colors(theme or "light")
2136
+ collapsed_ids = collapsed_ids or []
2137
+
2138
+ # Remove the item from canvas
2139
+ with _agent_state_lock:
2140
+ _agent_state["canvas"] = [
2141
+ item for item in _agent_state["canvas"]
2142
+ if item.get("id") != item_id
2143
+ ]
2144
+ canvas_items = _agent_state["canvas"].copy()
2145
+
2146
+ # Export updated canvas to markdown file
2147
+ try:
2148
+ export_canvas_to_markdown(canvas_items, WORKSPACE_ROOT)
2149
+ except Exception as e:
2150
+ print(f"Failed to export canvas after delete: {e}")
2151
+
2152
+ # Remove deleted item from collapsed_ids if present
2153
+ new_collapsed_ids = [cid for cid in collapsed_ids if cid != item_id]
2154
+
2155
+ # Render updated canvas with preserved collapsed state and close modal
2156
+ return render_canvas_items(canvas_items, colors, new_collapsed_ids), False, new_collapsed_ids
2157
+
2158
+ raise PreventUpdate
1601
2159
 
1602
2160
 
1603
2161
  # =============================================================================
@@ -1662,6 +2220,7 @@ def run_app(
1662
2220
  debug=None,
1663
2221
  title=None,
1664
2222
  subtitle=None,
2223
+ welcome_message=None,
1665
2224
  config_file=None
1666
2225
  ):
1667
2226
  """
@@ -1682,6 +2241,7 @@ def run_app(
1682
2241
  debug (bool, optional): Debug mode
1683
2242
  title (str, optional): Application title
1684
2243
  subtitle (str, optional): Application subtitle
2244
+ welcome_message (str, optional): Welcome message shown on startup (supports markdown)
1685
2245
  config_file (str, optional): Path to config file (default: ./config.py)
1686
2246
 
1687
2247
  Returns:
@@ -1702,7 +2262,7 @@ def run_app(
1702
2262
  >>> # Without agent (manual mode)
1703
2263
  >>> run_app(workspace="~/my-workspace", debug=True)
1704
2264
  """
1705
- global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, agent, AGENT_ERROR, args
2265
+ global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, WELCOME_MESSAGE, agent, AGENT_ERROR, args
1706
2266
 
1707
2267
  # Load config file if specified and exists
1708
2268
  config_module = None
@@ -1727,6 +2287,7 @@ def run_app(
1727
2287
  PORT = port if port is not None else getattr(config_module, "PORT", config.PORT)
1728
2288
  HOST = host if host else getattr(config_module, "HOST", config.HOST)
1729
2289
  DEBUG = debug if debug is not None else getattr(config_module, "DEBUG", config.DEBUG)
2290
+ WELCOME_MESSAGE = welcome_message if welcome_message else getattr(config_module, "WELCOME_MESSAGE", config.WELCOME_MESSAGE)
1730
2291
 
1731
2292
  # Agent priority: agent_spec > agent_instance > config file
1732
2293
  if agent_spec:
@@ -1757,6 +2318,7 @@ def run_app(
1757
2318
  PORT = port if port is not None else config.PORT
1758
2319
  HOST = host if host else config.HOST
1759
2320
  DEBUG = debug if debug is not None else config.DEBUG
2321
+ WELCOME_MESSAGE = welcome_message if welcome_message else config.WELCOME_MESSAGE
1760
2322
 
1761
2323
  # Agent priority: agent_spec > agent_instance > config default
1762
2324
  if agent_spec: