cowork-dash 0.1.2__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 ADDED
@@ -0,0 +1,1776 @@
1
+ import os
2
+ import uuid
3
+ import sys
4
+ import json
5
+ import base64
6
+ import re
7
+ import shutil
8
+ import platform
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ import argparse
13
+ import importlib.util
14
+ from pathlib import Path
15
+ from datetime import datetime
16
+ from typing import Optional, Dict, Any, List
17
+ from dotenv import load_dotenv
18
+ load_dotenv()
19
+
20
+ from dash import Dash, html, Input, Output, State, callback_context, no_update, ALL, clientside_callback
21
+ from dash.exceptions import PreventUpdate
22
+ import dash_mantine_components as dmc
23
+ from dash_iconify import DashIconify
24
+
25
+ # Import custom modules
26
+ from .canvas import parse_canvas_object, export_canvas_to_markdown, load_canvas_from_markdown
27
+ from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
28
+ from .components import (
29
+ format_message, format_loading, format_thinking, format_todos,
30
+ format_todos_inline, render_canvas_items, format_tool_calls_inline,
31
+ format_interrupt
32
+ )
33
+ from .layout import create_layout as create_layout_component
34
+
35
+ # Import configuration defaults
36
+ from . import config
37
+
38
+ # Generate thread ID
39
+ thread_id = str(uuid.uuid4())
40
+
41
+ # Parse command-line arguments early
42
+ def parse_args():
43
+ """Parse command-line arguments."""
44
+ parser = argparse.ArgumentParser(
45
+ description="FastDash Browser - AI Agent Web Interface",
46
+ formatter_class=argparse.RawDescriptionHelpFormatter,
47
+ epilog="""
48
+ Examples:
49
+ # Use defaults from config.py
50
+ python app.py
51
+
52
+ # Override workspace and port
53
+ python app.py --workspace ~/my-workspace --port 8080
54
+
55
+ # Use custom agent from file
56
+ python app.py --agent my_agents.py:my_agent
57
+
58
+ # Production mode
59
+ python app.py --host 0.0.0.0 --port 80 --no-debug
60
+
61
+ # Debug mode with custom workspace
62
+ python app.py --debug --workspace /tmp/test-workspace
63
+ """
64
+ )
65
+
66
+ parser.add_argument(
67
+ "--workspace",
68
+ type=str,
69
+ help="Workspace directory path (default: from config.py)"
70
+ )
71
+
72
+ parser.add_argument(
73
+ "--agent",
74
+ type=str,
75
+ metavar="PATH:OBJECT",
76
+ help='Agent specification as "path/to/file.py:object_name" (e.g., "agent.py:agent" or "my_agents.py:custom_agent")'
77
+ )
78
+
79
+ parser.add_argument(
80
+ "--port",
81
+ type=int,
82
+ help="Port to run on (default: from config.py)"
83
+ )
84
+
85
+ parser.add_argument(
86
+ "--host",
87
+ type=str,
88
+ help="Host to bind to (default: from config.py)"
89
+ )
90
+
91
+ parser.add_argument(
92
+ "--debug",
93
+ action="store_true",
94
+ help="Enable debug mode"
95
+ )
96
+
97
+ parser.add_argument(
98
+ "--no-debug",
99
+ action="store_true",
100
+ help="Disable debug mode"
101
+ )
102
+
103
+ parser.add_argument(
104
+ "--title",
105
+ type=str,
106
+ help="Application title (default: from config.py)"
107
+ )
108
+
109
+ parser.add_argument(
110
+ "--subtitle",
111
+ type=str,
112
+ help="Application subtitle (default: from config.py)"
113
+ )
114
+
115
+ parser.add_argument(
116
+ "--config",
117
+ type=str,
118
+ help="Path to configuration file (default: config.py)"
119
+ )
120
+
121
+ return parser.parse_args()
122
+
123
+ def load_agent_from_spec(agent_spec: str):
124
+ """
125
+ Load agent from specification string in format "path/to/file.py:object_name".
126
+
127
+ Args:
128
+ agent_spec: String like "agent.py:agent" or "my_agents.py:custom_agent"
129
+
130
+ Returns:
131
+ tuple: (agent_object, error_message)
132
+ """
133
+ try:
134
+ # Parse the spec
135
+ if ":" not in agent_spec:
136
+ return None, f"Invalid agent spec '{agent_spec}'. Expected format: 'path/to/file.py:object_name'"
137
+
138
+ file_path, object_name = agent_spec.rsplit(":", 1)
139
+ file_path = Path(file_path).resolve()
140
+
141
+ if not file_path.exists():
142
+ return None, f"Agent file not found: {file_path}"
143
+
144
+ # Load the module
145
+ spec = importlib.util.spec_from_file_location("custom_agent_module", file_path)
146
+ if spec is None or spec.loader is None:
147
+ return None, f"Failed to load module from {file_path}"
148
+
149
+ module = importlib.util.module_from_spec(spec)
150
+ sys.modules["custom_agent_module"] = module
151
+ spec.loader.exec_module(module)
152
+
153
+ # Get the object
154
+ if not hasattr(module, object_name):
155
+ return None, f"Object '{object_name}' not found in {file_path}"
156
+
157
+ agent = getattr(module, object_name)
158
+ return agent, None
159
+
160
+ except Exception as e:
161
+ return None, f"Failed to load agent from {agent_spec}: {e}"
162
+
163
+ # Module-level configuration (uses config defaults)
164
+ WORKSPACE_ROOT = config.WORKSPACE_ROOT
165
+ APP_TITLE = config.APP_TITLE
166
+ APP_SUBTITLE = config.APP_SUBTITLE
167
+ PORT = config.PORT
168
+ HOST = config.HOST
169
+ DEBUG = config.DEBUG
170
+
171
+ # Ensure workspace exists
172
+ WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
173
+
174
+ # Initialize agent from config
175
+ agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
176
+
177
+
178
+ # =============================================================================
179
+ # STYLING
180
+ # =============================================================================
181
+
182
+ COLORS_LIGHT = {
183
+ "bg_primary": "#ffffff",
184
+ "bg_secondary": "#f8f9fa",
185
+ "bg_tertiary": "#f1f3f4",
186
+ "bg_hover": "#e8eaed",
187
+ "accent": "#1a73e8",
188
+ "accent_light": "#e8f0fe",
189
+ "accent_dark": "#1557b0",
190
+ "text_primary": "#202124",
191
+ "text_secondary": "#5f6368",
192
+ "text_muted": "#80868b",
193
+ "border": "#dadce0",
194
+ "border_light": "#e8eaed",
195
+ "success": "#1e8e3e",
196
+ "warning": "#f9ab00",
197
+ "error": "#d93025",
198
+ "thinking": "#7c4dff",
199
+ "todo": "#00897b",
200
+ "canvas_bg": "#ffffff",
201
+ "interrupt_bg": "#fffbeb",
202
+ }
203
+
204
+ COLORS_DARK = {
205
+ "bg_primary": "#1e1e1e",
206
+ "bg_secondary": "#252526",
207
+ "bg_tertiary": "#2d2d2d",
208
+ "bg_hover": "#3c3c3c",
209
+ "accent": "#4fc3f7",
210
+ "accent_light": "#1e3a5f",
211
+ "accent_dark": "#81d4fa",
212
+ "text_primary": "#e0e0e0",
213
+ "text_secondary": "#b0b0b0",
214
+ "text_muted": "#808080",
215
+ "border": "#404040",
216
+ "border_light": "#333333",
217
+ "success": "#4caf50",
218
+ "warning": "#ffb74d",
219
+ "error": "#ef5350",
220
+ "thinking": "#b388ff",
221
+ "todo": "#26a69a",
222
+ "canvas_bg": "#2d2d2d",
223
+ "interrupt_bg": "#3d3520",
224
+ }
225
+
226
+ # Default to light theme
227
+ COLORS = COLORS_LIGHT.copy()
228
+
229
+ STYLES = {
230
+ "shadow": "0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)",
231
+ "transition": "all 0.15s ease",
232
+ }
233
+
234
+ def get_colors(theme: str = "light") -> dict:
235
+ """Get color scheme based on theme."""
236
+ return COLORS_DARK if theme == "dark" else COLORS_LIGHT
237
+
238
+ # Note: File utilities imported from file_utils module
239
+ # No local wrappers needed - file_utils functions will be called with WORKSPACE_ROOT
240
+
241
+ # =============================================================================
242
+ # AGENT INTERACTION - WITH REAL-TIME STREAMING
243
+ # =============================================================================
244
+
245
+ # Global state for streaming updates
246
+ _agent_state = {
247
+ "running": False,
248
+ "thinking": "",
249
+ "todos": [],
250
+ "tool_calls": [], # Current turn's tool calls (reset each turn)
251
+ "canvas": load_canvas_from_markdown(WORKSPACE_ROOT), # Load from canvas.md if exists
252
+ "response": "",
253
+ "error": None,
254
+ "interrupt": None, # Track interrupt requests for human-in-the-loop
255
+ "last_update": time.time(),
256
+ "start_time": None, # Track when agent started for response time calculation
257
+ }
258
+ _agent_state_lock = threading.Lock()
259
+
260
+ def _run_agent_stream(message: str, resume_data: Dict = None):
261
+ """Run agent in background thread and update global state in real-time.
262
+
263
+ Args:
264
+ message: User message to send to agent
265
+ resume_data: Optional dict with 'decisions' to resume from interrupt
266
+ """
267
+ if not agent:
268
+ with _agent_state_lock:
269
+ _agent_state["response"] = f"⚠️ {_agent_state['error']}\n\nPlease check your setup and try again."
270
+ _agent_state["running"] = False
271
+ return
272
+
273
+ # Track tool calls by their ID for updating status
274
+ tool_call_map = {}
275
+
276
+ def _serialize_tool_call(tc) -> Dict:
277
+ """Serialize a tool call to a dictionary."""
278
+ if isinstance(tc, dict):
279
+ return {
280
+ "id": tc.get("id"),
281
+ "name": tc.get("name"),
282
+ "args": tc.get("args", {}),
283
+ "status": "running",
284
+ "result": None
285
+ }
286
+ else:
287
+ return {
288
+ "id": getattr(tc, 'id', None),
289
+ "name": getattr(tc, 'name', None),
290
+ "args": getattr(tc, 'args', {}),
291
+ "status": "running",
292
+ "result": None
293
+ }
294
+
295
+ def _update_tool_call_result(tool_call_id: str, result: Any, status: str = "success"):
296
+ """Update a tool call with its result."""
297
+ with _agent_state_lock:
298
+ for tc in _agent_state["tool_calls"]:
299
+ if tc.get("id") == tool_call_id:
300
+ tc["result"] = result
301
+ tc["status"] = status
302
+ break
303
+ _agent_state["last_update"] = time.time()
304
+
305
+ try:
306
+ # Prepare input based on whether we're resuming or starting fresh
307
+ stream_config = dict(configurable=dict(thread_id=thread_id))
308
+
309
+ if message == "__RESUME__":
310
+ # Resume from interrupt
311
+ from langgraph.types import Command
312
+ agent_input = Command(resume=resume_data)
313
+ else:
314
+ agent_input = {"messages": [{"role": "user", "content": message}]}
315
+
316
+ for update in agent.stream(agent_input, stream_mode="updates", config=stream_config):
317
+ # Check for interrupt
318
+ if isinstance(update, dict) and "__interrupt__" in update:
319
+ interrupt_value = update["__interrupt__"]
320
+ interrupt_data = _process_interrupt(interrupt_value)
321
+ with _agent_state_lock:
322
+ _agent_state["interrupt"] = interrupt_data
323
+ _agent_state["running"] = False # Pause until user responds
324
+ _agent_state["last_update"] = time.time()
325
+ return # Exit stream, wait for user to resume
326
+
327
+ if isinstance(update, dict):
328
+ for _, state_data in update.items():
329
+ if isinstance(state_data, dict) and "messages" in state_data:
330
+ msgs = state_data["messages"]
331
+ if msgs:
332
+ last_msg = msgs[-1] if isinstance(msgs, list) else msgs
333
+ msg_type = last_msg.__class__.__name__ if hasattr(last_msg, '__class__') else None
334
+
335
+ # Capture AIMessage tool_calls
336
+ if msg_type == 'AIMessage' and hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
337
+ new_tool_calls = []
338
+ for tc in last_msg.tool_calls:
339
+ serialized = _serialize_tool_call(tc)
340
+ tool_call_map[serialized["id"]] = serialized
341
+ new_tool_calls.append(serialized)
342
+
343
+ with _agent_state_lock:
344
+ _agent_state["tool_calls"].extend(new_tool_calls)
345
+ _agent_state["last_update"] = time.time()
346
+
347
+ elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
348
+ # Update tool call status when we get the result
349
+ tool_call_id = getattr(last_msg, 'tool_call_id', None)
350
+ if tool_call_id:
351
+ # Determine status based on content
352
+ content = last_msg.content
353
+ status = "success"
354
+ if isinstance(content, str) and ("error" in content.lower() or "Error:" in content):
355
+ status = "error"
356
+ elif isinstance(content, dict) and content.get("error"):
357
+ status = "error"
358
+
359
+ # Truncate result for display
360
+ result_display = str(content)
361
+ if len(result_display) > 1000:
362
+ result_display = result_display[:1000] + "..."
363
+
364
+ _update_tool_call_result(tool_call_id, result_display, status)
365
+
366
+ # Handle specific tool messages
367
+ if last_msg.name == 'think_tool':
368
+ content = last_msg.content
369
+ thinking_text = ""
370
+ if isinstance(content, str):
371
+ try:
372
+ parsed = json.loads(content)
373
+ thinking_text = parsed.get('reflection', content)
374
+ except:
375
+ thinking_text = content
376
+ elif isinstance(content, dict):
377
+ thinking_text = content.get('reflection', str(content))
378
+
379
+ # Update state immediately
380
+ with _agent_state_lock:
381
+ _agent_state["thinking"] = thinking_text
382
+ _agent_state["last_update"] = time.time()
383
+
384
+ elif last_msg.name == 'write_todos':
385
+ content = last_msg.content
386
+ todos = []
387
+ if isinstance(content, str):
388
+ import ast
389
+ match = re.search(r'\[.*\]', content, re.DOTALL)
390
+ if match:
391
+ try:
392
+ todos = ast.literal_eval(match.group(0))
393
+ except:
394
+ try:
395
+ todos = json.loads(match.group(0))
396
+ except:
397
+ pass
398
+ elif isinstance(content, list):
399
+ todos = content
400
+
401
+ # Update state immediately
402
+ with _agent_state_lock:
403
+ _agent_state["todos"] = todos
404
+ _agent_state["last_update"] = time.time()
405
+
406
+ elif last_msg.name == 'add_to_canvas':
407
+ content = last_msg.content
408
+ # Canvas tool returns the parsed canvas object
409
+ if isinstance(content, str):
410
+ try:
411
+ parsed = json.loads(content)
412
+ canvas_item = parsed
413
+ except:
414
+ # If not JSON, treat as markdown
415
+ canvas_item = {"type": "markdown", "data": content}
416
+ elif isinstance(content, dict):
417
+ canvas_item = content
418
+ else:
419
+ canvas_item = {"type": "markdown", "data": str(content)}
420
+
421
+ # Update state immediately - append to canvas
422
+ with _agent_state_lock:
423
+ _agent_state["canvas"].append(canvas_item)
424
+ _agent_state["last_update"] = time.time()
425
+
426
+ # Also export to markdown file
427
+ try:
428
+ export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
429
+ except Exception as e:
430
+ print(f"Failed to export canvas: {e}")
431
+
432
+ elif last_msg.name in ('execute_cell', 'execute_all_cells'):
433
+ # Extract canvas_items from cell execution results
434
+ content = last_msg.content
435
+ canvas_items_to_add = []
436
+
437
+ if isinstance(content, str):
438
+ try:
439
+ parsed = json.loads(content)
440
+ # execute_cell returns a dict, execute_all_cells returns a list
441
+ if isinstance(parsed, dict):
442
+ canvas_items_to_add = parsed.get('canvas_items', [])
443
+ elif isinstance(parsed, list):
444
+ # execute_all_cells returns list of results
445
+ for result in parsed:
446
+ if isinstance(result, dict):
447
+ canvas_items_to_add.extend(result.get('canvas_items', []))
448
+ except:
449
+ pass
450
+ elif isinstance(content, dict):
451
+ canvas_items_to_add = content.get('canvas_items', [])
452
+ elif isinstance(content, list):
453
+ for result in content:
454
+ if isinstance(result, dict):
455
+ canvas_items_to_add.extend(result.get('canvas_items', []))
456
+
457
+ # Add any canvas items found
458
+ if canvas_items_to_add:
459
+ with _agent_state_lock:
460
+ for item in canvas_items_to_add:
461
+ if isinstance(item, dict) and item.get('type'):
462
+ _agent_state["canvas"].append(item)
463
+ _agent_state["last_update"] = time.time()
464
+
465
+ # Export to markdown file
466
+ try:
467
+ export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
468
+ except Exception as e:
469
+ print(f"Failed to export canvas: {e}")
470
+
471
+ elif hasattr(last_msg, 'content'):
472
+ content = last_msg.content
473
+ response_text = ""
474
+ if isinstance(content, str):
475
+ response_text = re.sub(
476
+ r"\{'id':\s*'[^']+',\s*'input':\s*\{.*?\},\s*'name':\s*'[^']+',\s*'type':\s*'tool_use'\}",
477
+ '', content, flags=re.DOTALL
478
+ ).strip()
479
+ elif isinstance(content, list):
480
+ text_parts = [
481
+ block.get("text", "") if isinstance(block, dict) else str(block)
482
+ for block in content
483
+ ]
484
+ response_text = " ".join(text_parts).strip()
485
+
486
+ if response_text:
487
+ with _agent_state_lock:
488
+ _agent_state["response"] = response_text
489
+ _agent_state["last_update"] = time.time()
490
+
491
+ except Exception as e:
492
+ with _agent_state_lock:
493
+ _agent_state["error"] = str(e)
494
+ _agent_state["response"] = f"Error: {str(e)}"
495
+
496
+ finally:
497
+ with _agent_state_lock:
498
+ _agent_state["running"] = False
499
+ _agent_state["last_update"] = time.time()
500
+
501
+
502
+ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
503
+ """Process a LangGraph interrupt value and convert to serializable format.
504
+
505
+ Args:
506
+ interrupt_value: The interrupt value from LangGraph
507
+
508
+ Returns:
509
+ Dict with 'message' and 'action_requests' for UI display
510
+ """
511
+ interrupt_data = {
512
+ "message": "The agent needs your input to continue.",
513
+ "action_requests": [],
514
+ "raw": None
515
+ }
516
+
517
+ # Handle different interrupt formats
518
+ if isinstance(interrupt_value, (list, tuple)) and len(interrupt_value) > 0:
519
+ first_item = interrupt_value[0]
520
+
521
+ # Check if it's an Interrupt object (from deepagents interrupt_on)
522
+ if hasattr(first_item, 'value'):
523
+ # This is a LangGraph Interrupt object
524
+ for item in interrupt_value:
525
+ value = getattr(item, 'value', None)
526
+
527
+ # deepagents interrupt_on stores tool call info in a specific format:
528
+ # {'action_requests': [{'name': 'bash', 'args': {...}, 'description': '...'}], 'review_configs': [...]}
529
+ if value is not None and isinstance(value, dict):
530
+ # Check for deepagents format with action_requests
531
+ action_requests = value.get('action_requests', [])
532
+ if action_requests:
533
+ for action_req in action_requests:
534
+ tool_name = action_req.get('name', 'unknown')
535
+ tool_args = action_req.get('args', {})
536
+ interrupt_data["action_requests"].append({
537
+ "type": "tool_call",
538
+ "tool": tool_name,
539
+ "args": tool_args,
540
+ })
541
+ interrupt_data["message"] = f"The agent wants to execute: {tool_name}"
542
+ else:
543
+ # Fallback: direct tool call format
544
+ tool_name = value.get('name', value.get('tool', 'unknown'))
545
+ tool_args = value.get('args', value.get('arguments', {}))
546
+ if tool_name != 'unknown':
547
+ interrupt_data["action_requests"].append({
548
+ "type": "tool_call",
549
+ "tool": tool_name,
550
+ "args": tool_args,
551
+ })
552
+ interrupt_data["message"] = f"The agent wants to execute: {tool_name}"
553
+ else:
554
+ interrupt_data["message"] = str(value)
555
+ elif value is not None:
556
+ interrupt_data["message"] = str(value)
557
+
558
+ # Check if it's an ActionRequest or similar
559
+ elif hasattr(first_item, 'action'):
560
+ for item in interrupt_value:
561
+ action = getattr(item, 'action', None)
562
+ if action:
563
+ interrupt_data["action_requests"].append({
564
+ "type": getattr(action, 'type', 'unknown'),
565
+ "tool": getattr(action, 'name', getattr(action, 'tool', '')),
566
+ "args": getattr(action, 'args', {}),
567
+ })
568
+ elif isinstance(first_item, dict):
569
+ # Check if it's a tool call dict
570
+ if 'name' in first_item or 'tool' in first_item:
571
+ for item in interrupt_value:
572
+ tool_name = item.get('name', item.get('tool', 'unknown'))
573
+ tool_args = item.get('args', item.get('arguments', {}))
574
+ interrupt_data["action_requests"].append({
575
+ "type": "tool_call",
576
+ "tool": tool_name,
577
+ "args": tool_args,
578
+ })
579
+ interrupt_data["message"] = f"The agent wants to execute: {tool_name}"
580
+ else:
581
+ interrupt_data["action_requests"] = list(interrupt_value)
582
+ else:
583
+ interrupt_data["message"] = str(first_item)
584
+ elif isinstance(interrupt_value, str):
585
+ interrupt_data["message"] = interrupt_value
586
+ elif isinstance(interrupt_value, dict):
587
+ interrupt_data["message"] = interrupt_value.get("message", str(interrupt_value))
588
+ interrupt_data["action_requests"] = interrupt_value.get("action_requests", [])
589
+
590
+ # Store raw value for resume
591
+ try:
592
+ interrupt_data["raw"] = interrupt_value
593
+ except:
594
+ pass
595
+
596
+ return interrupt_data
597
+
598
+ def call_agent(message: str, resume_data: Dict = None):
599
+ """Start agent execution in background thread.
600
+
601
+ Args:
602
+ message: User message to send to agent
603
+ resume_data: Optional dict with decisions to resume from interrupt
604
+ """
605
+ # Reset state but preserve canvas - do it all atomically
606
+ with _agent_state_lock:
607
+ existing_canvas = _agent_state.get("canvas", []).copy()
608
+
609
+ _agent_state.clear()
610
+ _agent_state.update({
611
+ "running": True,
612
+ "thinking": "",
613
+ "todos": [],
614
+ "tool_calls": [], # Reset tool calls for this turn
615
+ "canvas": existing_canvas, # Preserve existing canvas
616
+ "response": "",
617
+ "error": None,
618
+ "interrupt": None, # Clear any previous interrupt
619
+ "last_update": time.time(),
620
+ "start_time": time.time(), # Track when agent started
621
+ })
622
+
623
+ # Start background thread
624
+ thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data))
625
+ thread.daemon = True
626
+ thread.start()
627
+
628
+
629
+ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_requests: List[Dict] = None):
630
+ """Resume agent from an interrupt with the user's decision.
631
+
632
+ Args:
633
+ decision: User's response/decision text
634
+ action: One of 'approve', 'reject', 'edit'
635
+ action_requests: List of action requests from the interrupt (for edit mode)
636
+ """
637
+ with _agent_state_lock:
638
+ interrupt_data = _agent_state.get("interrupt")
639
+ if not interrupt_data:
640
+ return
641
+
642
+ # Get action requests from interrupt data if not provided
643
+ if action_requests is None:
644
+ action_requests = interrupt_data.get("action_requests", [])
645
+
646
+ # Clear interrupt and set running, but preserve tool_calls and canvas
647
+ existing_tool_calls = _agent_state.get("tool_calls", []).copy()
648
+ existing_canvas = _agent_state.get("canvas", []).copy()
649
+
650
+ _agent_state["interrupt"] = None
651
+ _agent_state["running"] = True
652
+ _agent_state["response"] = "" # Clear any previous response
653
+ _agent_state["error"] = None # Clear any previous error
654
+ _agent_state["tool_calls"] = existing_tool_calls # Keep existing tool calls
655
+ _agent_state["canvas"] = existing_canvas # Keep canvas
656
+ _agent_state["last_update"] = time.time()
657
+
658
+ # Build decisions list in the format expected by deepagents HITL middleware
659
+ # Format: {"decisions": [{"type": "approve"}, {"type": "reject", "message": "..."}, ...]}
660
+ decisions = []
661
+
662
+ if action == "approve":
663
+ # Approve all action requests
664
+ for _ in action_requests:
665
+ decisions.append({"type": "approve"})
666
+ # If no action requests, still add one approve decision
667
+ if not decisions:
668
+ decisions.append({"type": "approve"})
669
+ elif action == "reject":
670
+ # When user rejects, stop the agent immediately instead of resuming
671
+ # Set the response to indicate the action was rejected
672
+ reject_message = decision or "User rejected the action"
673
+
674
+ # Get tool info for the rejection message
675
+ tool_info = ""
676
+ if action_requests:
677
+ tool_names = [ar.get("tool", "unknown") for ar in action_requests]
678
+ tool_info = f" ({', '.join(tool_names)})"
679
+
680
+ with _agent_state_lock:
681
+ _agent_state["running"] = False
682
+ _agent_state["response"] = f"Action rejected{tool_info}: {reject_message}"
683
+ _agent_state["last_update"] = time.time()
684
+
685
+ return # Don't resume the agent
686
+ else: # edit - provide edited action
687
+ # For edit, we need to provide the edited tool call
688
+ # The decision text should contain the edited command/args
689
+ for action_req in action_requests:
690
+ tool_name = action_req.get("tool", "")
691
+
692
+ # If this is a bash command and user provided new command text
693
+ if tool_name == "bash" and decision:
694
+ decisions.append({
695
+ "type": "edit",
696
+ "edited_action": {
697
+ "name": tool_name,
698
+ "args": {"command": decision}
699
+ }
700
+ })
701
+ else:
702
+ # For other tools or no input, just approve
703
+ decisions.append({"type": "approve"})
704
+
705
+ if not decisions:
706
+ decisions.append({"type": "approve"})
707
+
708
+ # Resume value in deepagents format
709
+ resume_value = {"decisions": decisions}
710
+
711
+ # Start background thread with resume value
712
+ # Pass a special marker to indicate this is a resume operation
713
+ thread = threading.Thread(target=_run_agent_stream, args=("__RESUME__", resume_value))
714
+ thread.daemon = True
715
+ thread.start()
716
+
717
+ def get_agent_state() -> Dict[str, Any]:
718
+ """Get current agent state (thread-safe)."""
719
+ with _agent_state_lock:
720
+ return _agent_state.copy()
721
+
722
+ # =============================================================================
723
+ # DASH APP
724
+ # =============================================================================
725
+
726
+ app = Dash(
727
+ __name__,
728
+ suppress_callback_exceptions=True,
729
+ title=APP_TITLE,
730
+ external_stylesheets=dmc.styles.ALL,
731
+ external_scripts=[
732
+ "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js",
733
+ ],
734
+ assets_folder=str(Path(__file__).parent / "assets"),
735
+ )
736
+
737
+ # Custom index string for SVG favicon support
738
+ app.index_string = '''<!DOCTYPE html>
739
+ <html>
740
+ <head>
741
+ {%metas%}
742
+ <title>{%title%}</title>
743
+ <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
744
+ {%css%}
745
+ </head>
746
+ <body>
747
+ {%app_entry%}
748
+ <footer>
749
+ {%config%}
750
+ {%scripts%}
751
+ {%renderer%}
752
+ </footer>
753
+ </body>
754
+ </html>'''
755
+
756
+
757
+ # =============================================================================
758
+ # LAYOUT
759
+ # =============================================================================
760
+
761
+ def create_layout():
762
+ """Create the app layout with current configuration."""
763
+ return create_layout_component(
764
+ workspace_root=WORKSPACE_ROOT,
765
+ app_title=APP_TITLE,
766
+ app_subtitle=APP_SUBTITLE,
767
+ colors=COLORS,
768
+ styles=STYLES,
769
+ agent=agent
770
+ )
771
+
772
+ # Set layout as a function so it uses current WORKSPACE_ROOT
773
+ app.layout = create_layout
774
+
775
+ # Note: Component rendering functions imported from components module
776
+ # These are used in callbacks below with COLORS and STYLES passed as parameters
777
+
778
+ # =============================================================================
779
+ # CALLBACKS
780
+ # =============================================================================
781
+
782
+ # Initial message display
783
+ @app.callback(
784
+ Output("chat-messages", "children"),
785
+ [Input("chat-history", "data")],
786
+ [State("theme-store", "data")],
787
+ prevent_initial_call=False
788
+ )
789
+ def display_initial_messages(history, theme):
790
+ """Display initial welcome message or chat history."""
791
+ if not history:
792
+ return []
793
+
794
+ colors = get_colors(theme or "light")
795
+ messages = []
796
+ for msg in history:
797
+ msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
798
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
799
+ # Render tool calls stored with this message
800
+ if msg.get("tool_calls"):
801
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
802
+ if tool_calls_block:
803
+ messages.append(tool_calls_block)
804
+ # Render todos stored with this message
805
+ if msg.get("todos"):
806
+ todos_block = format_todos_inline(msg["todos"], colors)
807
+ if todos_block:
808
+ messages.append(todos_block)
809
+ return messages
810
+
811
+ # Chat callbacks
812
+ @app.callback(
813
+ [Output("chat-messages", "children", allow_duplicate=True),
814
+ Output("chat-history", "data", allow_duplicate=True),
815
+ Output("chat-input", "value"),
816
+ Output("pending-message", "data"),
817
+ Output("poll-interval", "disabled")],
818
+ [Input("send-btn", "n_clicks"),
819
+ Input("chat-input", "n_submit")],
820
+ [State("chat-input", "value"),
821
+ State("chat-history", "data"),
822
+ State("theme-store", "data")],
823
+ prevent_initial_call=True
824
+ )
825
+ def handle_send_immediate(n_clicks, n_submit, message, history, theme):
826
+ """Phase 1: Immediately show user message and start agent."""
827
+ if not message or not message.strip():
828
+ raise PreventUpdate
829
+
830
+ colors = get_colors(theme or "light")
831
+ message = message.strip()
832
+ history = history or []
833
+ history.append({"role": "user", "content": message})
834
+
835
+ # Render all history messages including tool calls and todos
836
+ messages = []
837
+ for i, m in enumerate(history):
838
+ is_new = (i == len(history) - 1)
839
+ msg_response_time = m.get("response_time") if m["role"] == "assistant" else None
840
+ messages.append(format_message(m["role"], m["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
841
+ # Render tool calls stored with this message
842
+ if m.get("tool_calls"):
843
+ tool_calls_block = format_tool_calls_inline(m["tool_calls"], colors)
844
+ if tool_calls_block:
845
+ messages.append(tool_calls_block)
846
+ # Render todos stored with this message
847
+ if m.get("todos"):
848
+ todos_block = format_todos_inline(m["todos"], colors)
849
+ if todos_block:
850
+ messages.append(todos_block)
851
+
852
+ messages.append(format_loading(colors))
853
+
854
+ # Start agent in background
855
+ call_agent(message)
856
+
857
+ # Enable polling
858
+ return messages, history, "", message, False
859
+
860
+
861
+ @app.callback(
862
+ [Output("chat-messages", "children", allow_duplicate=True),
863
+ Output("chat-history", "data", allow_duplicate=True),
864
+ Output("poll-interval", "disabled", allow_duplicate=True)],
865
+ Input("poll-interval", "n_intervals"),
866
+ [State("chat-history", "data"),
867
+ State("pending-message", "data"),
868
+ State("theme-store", "data")],
869
+ prevent_initial_call=True
870
+ )
871
+ def poll_agent_updates(n_intervals, history, pending_message, theme):
872
+ """Poll for agent updates and display them in real-time.
873
+
874
+ Tool calls are stored in history and persist across turns.
875
+ History items can be:
876
+ - {"role": "user", "content": "..."} - user message
877
+ - {"role": "assistant", "content": "...", "tool_calls": [...]} - assistant message with tool calls
878
+ """
879
+ state = get_agent_state()
880
+ history = history or []
881
+ colors = get_colors(theme or "light")
882
+
883
+ def render_history_messages(history_items):
884
+ """Render all history items including tool calls and todos."""
885
+ messages = []
886
+ for msg in history_items:
887
+ msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
888
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
889
+ # Render tool calls stored with this message
890
+ if msg.get("tool_calls"):
891
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
892
+ if tool_calls_block:
893
+ messages.append(tool_calls_block)
894
+ # Render todos stored with this message
895
+ if msg.get("todos"):
896
+ todos_block = format_todos_inline(msg["todos"], colors)
897
+ if todos_block:
898
+ messages.append(todos_block)
899
+ return messages
900
+
901
+ # Check for interrupt (human-in-the-loop)
902
+ if state.get("interrupt"):
903
+ # Agent is paused waiting for user input
904
+ messages = render_history_messages(history)
905
+
906
+ # Add current turn's thinking/tool_calls/todos before interrupt
907
+ if state["thinking"]:
908
+ thinking_block = format_thinking(state["thinking"], colors)
909
+ if thinking_block:
910
+ messages.append(thinking_block)
911
+
912
+ if state.get("tool_calls"):
913
+ tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
914
+ if tool_calls_block:
915
+ messages.append(tool_calls_block)
916
+
917
+ if state["todos"]:
918
+ todos_block = format_todos_inline(state["todos"], colors)
919
+ if todos_block:
920
+ messages.append(todos_block)
921
+
922
+ # Add interrupt UI
923
+ interrupt_block = format_interrupt(state["interrupt"], colors)
924
+ if interrupt_block:
925
+ messages.append(interrupt_block)
926
+
927
+ # Disable polling - wait for user to respond to interrupt
928
+ return messages, no_update, True
929
+
930
+ # Check if agent is done
931
+ if not state["running"]:
932
+ # Calculate response time
933
+ response_time = None
934
+ if state.get("start_time"):
935
+ response_time = time.time() - state["start_time"]
936
+
937
+ # Agent finished - store tool calls and todos with the USER message (they appear after user msg)
938
+ if history:
939
+ # Find the last user message and attach tool calls and todos to it
940
+ for i in range(len(history) - 1, -1, -1):
941
+ if history[i]["role"] == "user":
942
+ if state.get("tool_calls"):
943
+ history[i]["tool_calls"] = state["tool_calls"]
944
+ if state.get("todos"):
945
+ history[i]["todos"] = state["todos"]
946
+ break
947
+
948
+ # Add assistant response to history (with response time)
949
+ assistant_msg = {
950
+ "role": "assistant",
951
+ "content": state["response"] if state["response"] else f"Error: {state['error']}",
952
+ "response_time": response_time,
953
+ }
954
+
955
+ history.append(assistant_msg)
956
+
957
+ # Render all history (tool calls and todos are now part of history)
958
+ final_messages = []
959
+ for i, msg in enumerate(history):
960
+ is_new = (i >= len(history) - 1)
961
+ msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
962
+ final_messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
963
+ # Render tool calls stored with this message
964
+ if msg.get("tool_calls"):
965
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
966
+ if tool_calls_block:
967
+ final_messages.append(tool_calls_block)
968
+ # Render todos stored with this message
969
+ if msg.get("todos"):
970
+ todos_block = format_todos_inline(msg["todos"], colors)
971
+ if todos_block:
972
+ final_messages.append(todos_block)
973
+
974
+ # Disable polling
975
+ return final_messages, history, True
976
+ else:
977
+ # Agent still running - show loading with current thinking/tool_calls/todos
978
+ messages = render_history_messages(history)
979
+
980
+ # Add current thinking if available
981
+ if state["thinking"]:
982
+ thinking_block = format_thinking(state["thinking"], colors)
983
+ if thinking_block:
984
+ messages.append(thinking_block)
985
+
986
+ # Add current tool calls if available
987
+ if state.get("tool_calls"):
988
+ tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
989
+ if tool_calls_block:
990
+ messages.append(tool_calls_block)
991
+
992
+ # Add current todos if available
993
+ if state["todos"]:
994
+ todos_block = format_todos_inline(state["todos"], colors)
995
+ if todos_block:
996
+ messages.append(todos_block)
997
+
998
+ # Add loading indicator
999
+ messages.append(format_loading(colors))
1000
+
1001
+ # Continue polling
1002
+ return messages, no_update, False
1003
+
1004
+
1005
+ # Interrupt handling callbacks
1006
+ @app.callback(
1007
+ [Output("chat-messages", "children", allow_duplicate=True),
1008
+ Output("poll-interval", "disabled", allow_duplicate=True)],
1009
+ [Input("interrupt-approve-btn", "n_clicks"),
1010
+ Input("interrupt-reject-btn", "n_clicks"),
1011
+ Input("interrupt-edit-btn", "n_clicks")],
1012
+ [State("interrupt-input", "value"),
1013
+ State("chat-history", "data"),
1014
+ State("theme-store", "data")],
1015
+ prevent_initial_call=True
1016
+ )
1017
+ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_value, history, theme):
1018
+ """Handle user response to an interrupt.
1019
+
1020
+ Note: Click parameters are required for Dash callback inputs but we use
1021
+ ctx.triggered to determine which button was clicked.
1022
+ """
1023
+ ctx = callback_context
1024
+ if not ctx.triggered:
1025
+ raise PreventUpdate
1026
+
1027
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
1028
+ triggered_value = ctx.triggered[0].get("value")
1029
+
1030
+ # Only proceed if there was an actual click (value > 0)
1031
+ if not triggered_value or triggered_value <= 0:
1032
+ raise PreventUpdate
1033
+
1034
+ colors = get_colors(theme or "light")
1035
+ history = history or []
1036
+
1037
+ # Determine action based on which button was clicked
1038
+ if triggered_id == "interrupt-approve-btn":
1039
+ if not approve_clicks or approve_clicks <= 0:
1040
+ raise PreventUpdate
1041
+ action = "approve"
1042
+ decision = input_value or "approved"
1043
+ elif triggered_id == "interrupt-reject-btn":
1044
+ if not reject_clicks or reject_clicks <= 0:
1045
+ raise PreventUpdate
1046
+ action = "reject"
1047
+ decision = input_value or "rejected"
1048
+ elif triggered_id == "interrupt-edit-btn":
1049
+ if not edit_clicks or edit_clicks <= 0:
1050
+ raise PreventUpdate
1051
+ action = "edit"
1052
+ decision = input_value or ""
1053
+ if not decision:
1054
+ raise PreventUpdate # Need input for edit action
1055
+ else:
1056
+ raise PreventUpdate
1057
+
1058
+ # Resume the agent with the user's decision
1059
+ resume_agent_from_interrupt(decision, action)
1060
+
1061
+ # Show loading state while agent resumes
1062
+ messages = []
1063
+ for msg in history:
1064
+ msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
1065
+ messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
1066
+ # Render tool calls stored with this message
1067
+ if msg.get("tool_calls"):
1068
+ tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
1069
+ if tool_calls_block:
1070
+ messages.append(tool_calls_block)
1071
+ # Render todos stored with this message
1072
+ if msg.get("todos"):
1073
+ todos_block = format_todos_inline(msg["todos"], colors)
1074
+ if todos_block:
1075
+ messages.append(todos_block)
1076
+
1077
+ messages.append(format_loading(colors))
1078
+
1079
+ # Re-enable polling
1080
+ return messages, False
1081
+
1082
+
1083
+ # Folder toggle callback
1084
+ @app.callback(
1085
+ [Output({"type": "folder-children", "path": ALL}, "style"),
1086
+ Output({"type": "folder-icon", "path": ALL}, "style"),
1087
+ Output({"type": "folder-children", "path": ALL}, "children")],
1088
+ Input({"type": "folder-header", "path": ALL}, "n_clicks"),
1089
+ [State({"type": "folder-header", "path": ALL}, "data-realpath"),
1090
+ State({"type": "folder-children", "path": ALL}, "id"),
1091
+ State({"type": "folder-icon", "path": ALL}, "id"),
1092
+ State({"type": "folder-children", "path": ALL}, "style"),
1093
+ State({"type": "folder-icon", "path": ALL}, "style"),
1094
+ State({"type": "folder-children", "path": ALL}, "children"),
1095
+ State("theme-store", "data")],
1096
+ prevent_initial_call=True
1097
+ )
1098
+ def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
1099
+ """Toggle folder expansion and lazy load contents if needed."""
1100
+ ctx = callback_context
1101
+ if not ctx.triggered or not any(n_clicks):
1102
+ raise PreventUpdate
1103
+
1104
+ colors = get_colors(theme or "light")
1105
+ triggered = ctx.triggered[0]["prop_id"]
1106
+ try:
1107
+ id_str = triggered.rsplit(".", 1)[0]
1108
+ id_dict = json.loads(id_str)
1109
+ clicked_path = id_dict.get("path")
1110
+ except:
1111
+ raise PreventUpdate
1112
+
1113
+ # Find the index of the clicked folder to get its real path
1114
+ clicked_idx = None
1115
+ for i, icon_id in enumerate(icon_ids):
1116
+ if icon_id["path"] == clicked_path:
1117
+ clicked_idx = i
1118
+ break
1119
+
1120
+ if clicked_idx is None:
1121
+ raise PreventUpdate
1122
+
1123
+ folder_rel_path = real_paths[clicked_idx] if clicked_idx < len(real_paths) else None
1124
+ if not folder_rel_path:
1125
+ raise PreventUpdate
1126
+
1127
+ new_children_styles = []
1128
+ new_icon_styles = []
1129
+ new_children_content = []
1130
+
1131
+ # Process all folder-children elements
1132
+ for i, child_id in enumerate(children_ids):
1133
+ path = child_id["path"]
1134
+ current_style = children_styles[i] if i < len(children_styles) else {"display": "none"}
1135
+ current_content = children_content[i] if i < len(children_content) else []
1136
+
1137
+ if path == clicked_path:
1138
+ # Toggle this folder
1139
+ is_expanded = current_style.get("display") != "none"
1140
+ new_children_styles.append({"display": "none" if is_expanded else "block"})
1141
+
1142
+ # If expanding and content is just "Loading...", load the actual contents
1143
+ if not is_expanded and current_content:
1144
+ # Check if content is the loading placeholder
1145
+ if (isinstance(current_content, list) and len(current_content) == 1 and
1146
+ isinstance(current_content[0], dict) and
1147
+ current_content[0].get("props", {}).get("children") == "Loading..."):
1148
+ # Load folder contents using real path
1149
+ try:
1150
+ folder_items = load_folder_contents(folder_rel_path, WORKSPACE_ROOT)
1151
+ loaded_content = render_file_tree(folder_items, colors, STYLES,
1152
+ level=folder_rel_path.count("/") + 1,
1153
+ parent_path=folder_rel_path)
1154
+ new_children_content.append(loaded_content if loaded_content else current_content)
1155
+ except Exception as e:
1156
+ print(f"Error loading folder {folder_rel_path}: {e}")
1157
+ new_children_content.append(current_content)
1158
+ else:
1159
+ new_children_content.append(current_content)
1160
+ else:
1161
+ new_children_content.append(current_content)
1162
+ else:
1163
+ new_children_styles.append(current_style)
1164
+ new_children_content.append(current_content)
1165
+
1166
+ # Process all folder-icon elements
1167
+ for i, icon_id in enumerate(icon_ids):
1168
+ path = icon_id["path"]
1169
+ current_icon_style = icon_styles[i] if i < len(icon_styles) else {}
1170
+
1171
+ if path == clicked_path:
1172
+ # Find corresponding children style to check if expanded
1173
+ children_idx = next((idx for idx, cid in enumerate(children_ids) if cid["path"] == path), None)
1174
+ if children_idx is not None:
1175
+ current_children_style = children_styles[children_idx] if children_idx < len(children_styles) else {"display": "none"}
1176
+ is_expanded = current_children_style.get("display") != "none"
1177
+ new_icon_styles.append({
1178
+ "marginRight": "8px",
1179
+ "fontSize": "10px",
1180
+ "color": colors["text_muted"],
1181
+ "transition": "transform 0.2s",
1182
+ "display": "inline-block",
1183
+ "transform": "rotate(0deg)" if is_expanded else "rotate(90deg)",
1184
+ })
1185
+ else:
1186
+ new_icon_styles.append(current_icon_style)
1187
+ else:
1188
+ new_icon_styles.append(current_icon_style)
1189
+
1190
+ return new_children_styles, new_icon_styles, new_children_content
1191
+
1192
+
1193
+ # File click - open modal
1194
+ @app.callback(
1195
+ [Output("file-modal", "opened"),
1196
+ Output("file-modal", "title"),
1197
+ Output("modal-content", "children"),
1198
+ Output("file-to-view", "data"),
1199
+ Output("file-click-tracker", "data")],
1200
+ Input({"type": "file-item", "path": ALL}, "n_clicks"),
1201
+ [State({"type": "file-item", "path": ALL}, "id"),
1202
+ State("file-click-tracker", "data"),
1203
+ State("theme-store", "data")],
1204
+ prevent_initial_call=True
1205
+ )
1206
+ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
1207
+ """Open file in modal - only on actual new clicks."""
1208
+ ctx = callback_context
1209
+
1210
+ if not ctx.triggered_id:
1211
+ raise PreventUpdate
1212
+
1213
+ # ctx.triggered_id is the dict {"type": "file-item", "path": "..."}
1214
+ if not isinstance(ctx.triggered_id, dict):
1215
+ raise PreventUpdate
1216
+
1217
+ if ctx.triggered_id.get("type") != "file-item":
1218
+ raise PreventUpdate
1219
+
1220
+ file_path = ctx.triggered_id.get("path")
1221
+ if not file_path:
1222
+ raise PreventUpdate
1223
+
1224
+ # Find the index of the triggered item to get its click count
1225
+ clicked_idx = None
1226
+ for i, item_id in enumerate(all_ids):
1227
+ if item_id.get("path") == file_path:
1228
+ clicked_idx = i
1229
+ break
1230
+
1231
+ if clicked_idx is None:
1232
+ raise PreventUpdate
1233
+
1234
+ # Get current click count for this file
1235
+ current_clicks = all_n_clicks[clicked_idx] if clicked_idx < len(all_n_clicks) else None
1236
+
1237
+ # Must be an actual click (not None, not 0)
1238
+ if not current_clicks:
1239
+ raise PreventUpdate
1240
+
1241
+ # Check if this is a NEW click vs a re-render with existing clicks
1242
+ click_tracker = click_tracker or {}
1243
+ prev_clicks = click_tracker.get(file_path, 0)
1244
+
1245
+ # Update tracker regardless of whether we open modal
1246
+ new_tracker = click_tracker.copy()
1247
+ new_tracker[file_path] = current_clicks
1248
+
1249
+ if current_clicks <= prev_clicks:
1250
+ # Not a new click - component was re-rendered or this click was already processed
1251
+ # Still need to return updated tracker to avoid stale state
1252
+ raise PreventUpdate
1253
+
1254
+ # Verify file exists and is a file
1255
+ full_path = WORKSPACE_ROOT / file_path
1256
+ if not full_path.exists() or not full_path.is_file():
1257
+ raise PreventUpdate
1258
+
1259
+ colors = get_colors(theme or "light")
1260
+ content, is_text, error = read_file_content(WORKSPACE_ROOT, file_path)
1261
+ filename = Path(file_path).name
1262
+
1263
+ if is_text and content:
1264
+ modal_content = html.Pre(
1265
+ content,
1266
+ style={
1267
+ "background": colors["bg_tertiary"],
1268
+ "padding": "16px",
1269
+ "fontSize": "12px",
1270
+ "fontFamily": "'IBM Plex Mono', monospace",
1271
+ "overflow": "auto",
1272
+ "maxHeight": "60vh",
1273
+ "whiteSpace": "pre-wrap",
1274
+ "wordBreak": "break-word",
1275
+ "margin": "0",
1276
+ "color": colors["text_primary"],
1277
+ }
1278
+ )
1279
+ else:
1280
+ modal_content = html.Div([
1281
+ html.P(error or "Cannot display file", style={
1282
+ "color": colors["text_muted"],
1283
+ "textAlign": "center",
1284
+ "padding": "40px",
1285
+ }),
1286
+ html.P("Click Download to save the file.", style={
1287
+ "color": colors["text_muted"],
1288
+ "textAlign": "center",
1289
+ "fontSize": "13px",
1290
+ })
1291
+ ])
1292
+
1293
+ return True, filename, modal_content, file_path, new_tracker
1294
+
1295
+ # Modal download button
1296
+ @app.callback(
1297
+ Output("file-download", "data", allow_duplicate=True),
1298
+ Input("modal-download-btn", "n_clicks"),
1299
+ State("file-to-view", "data"),
1300
+ prevent_initial_call=True
1301
+ )
1302
+ def download_from_modal(n_clicks, file_path):
1303
+ """Download file from modal."""
1304
+ ctx = callback_context
1305
+ if not ctx.triggered:
1306
+ raise PreventUpdate
1307
+
1308
+ # Verify this callback was actually triggered by the download button
1309
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
1310
+ if triggered_id != "modal-download-btn":
1311
+ raise PreventUpdate
1312
+
1313
+ if not n_clicks or not file_path:
1314
+ raise PreventUpdate
1315
+
1316
+ b64, filename, mime = get_file_download_data(WORKSPACE_ROOT, file_path)
1317
+ if not b64:
1318
+ raise PreventUpdate
1319
+
1320
+ return dict(content=b64, filename=filename, base64=True, type=mime)
1321
+
1322
+
1323
+ # Open terminal
1324
+ @app.callback(
1325
+ Output("open-terminal-btn", "n_clicks"),
1326
+ Input("open-terminal-btn", "n_clicks"),
1327
+ prevent_initial_call=True
1328
+ )
1329
+ def open_terminal(n_clicks):
1330
+ """Open system terminal at workspace directory."""
1331
+ if not n_clicks:
1332
+ raise PreventUpdate
1333
+
1334
+ workspace_path = str(WORKSPACE_ROOT)
1335
+ system = platform.system()
1336
+
1337
+ try:
1338
+ if system == "Darwin": # macOS
1339
+ subprocess.Popen(["open", "-a", "Terminal", workspace_path])
1340
+ elif system == "Windows":
1341
+ subprocess.Popen(["cmd", "/c", "start", "cmd", "/K", f"cd /d {workspace_path}"], shell=True)
1342
+ else: # Linux
1343
+ # Try common terminal emulators
1344
+ terminals = [
1345
+ ["gnome-terminal", f"--working-directory={workspace_path}"],
1346
+ ["konsole", f"--workdir={workspace_path}"],
1347
+ ["xfce4-terminal", f"--working-directory={workspace_path}"],
1348
+ ["xterm", "-e", f"cd {workspace_path} && $SHELL"],
1349
+ ]
1350
+ for term_cmd in terminals:
1351
+ try:
1352
+ subprocess.Popen(term_cmd)
1353
+ break
1354
+ except FileNotFoundError:
1355
+ continue
1356
+ except Exception as e:
1357
+ print(f"Failed to open terminal: {e}")
1358
+
1359
+ raise PreventUpdate
1360
+
1361
+
1362
+ # Refresh both file tree and canvas content
1363
+ @app.callback(
1364
+ [Output("file-tree", "children"),
1365
+ Output("canvas-content", "children", allow_duplicate=True)],
1366
+ Input("refresh-btn", "n_clicks"),
1367
+ [State("theme-store", "data")],
1368
+ prevent_initial_call=True
1369
+ )
1370
+ def refresh_sidebar(n_clicks, theme):
1371
+ """Refresh both file tree and canvas content."""
1372
+ global _agent_state
1373
+ colors = get_colors(theme or "light")
1374
+
1375
+ # Refresh file tree
1376
+ file_tree = render_file_tree(build_file_tree(WORKSPACE_ROOT, WORKSPACE_ROOT), colors, STYLES)
1377
+
1378
+ # Refresh canvas by reloading from .canvas/canvas.md file
1379
+ canvas_items = load_canvas_from_markdown(WORKSPACE_ROOT)
1380
+
1381
+ # Update agent state with reloaded canvas
1382
+ with _agent_state_lock:
1383
+ _agent_state["canvas"] = canvas_items
1384
+
1385
+ # Render the canvas items
1386
+ canvas_content = render_canvas_items(canvas_items, colors)
1387
+
1388
+ return file_tree, canvas_content
1389
+
1390
+
1391
+ # File upload
1392
+ @app.callback(
1393
+ [Output("upload-status", "children"),
1394
+ Output("file-tree", "children", allow_duplicate=True)],
1395
+ Input("file-upload", "contents"),
1396
+ [State("file-upload", "filename"),
1397
+ State("theme-store", "data")],
1398
+ prevent_initial_call=True
1399
+ )
1400
+ def handle_upload(contents, filenames, theme):
1401
+ """Handle file uploads."""
1402
+ if not contents:
1403
+ raise PreventUpdate
1404
+
1405
+ colors = get_colors(theme or "light")
1406
+ uploaded = []
1407
+ for content, filename in zip(contents, filenames):
1408
+ try:
1409
+ _, content_string = content.split(',')
1410
+ decoded = base64.b64decode(content_string)
1411
+ file_path = WORKSPACE_ROOT / filename
1412
+ try:
1413
+ file_path.write_text(decoded.decode('utf-8'))
1414
+ except UnicodeDecodeError:
1415
+ file_path.write_bytes(decoded)
1416
+ uploaded.append(filename)
1417
+ except Exception as e:
1418
+ print(f"Upload error: {e}")
1419
+
1420
+ if uploaded:
1421
+ return f"Uploaded: {', '.join(uploaded)}", render_file_tree(build_file_tree(WORKSPACE_ROOT, WORKSPACE_ROOT), colors, STYLES)
1422
+ return "Upload failed", no_update
1423
+
1424
+
1425
+ # View toggle callbacks - using SegmentedControl
1426
+ @app.callback(
1427
+ [Output("files-view", "style"),
1428
+ Output("canvas-view", "style"),
1429
+ Output("open-terminal-btn", "style")],
1430
+ [Input("sidebar-view-toggle", "value")],
1431
+ prevent_initial_call=True
1432
+ )
1433
+ def toggle_view(view_value):
1434
+ """Toggle between files and canvas view using SegmentedControl."""
1435
+ if not view_value:
1436
+ raise PreventUpdate
1437
+
1438
+ if view_value == "canvas":
1439
+ # Show canvas, hide files, hide terminal button (not relevant for canvas)
1440
+ return (
1441
+ {"flex": "1", "display": "none", "flexDirection": "column"},
1442
+ {
1443
+ "flex": "1",
1444
+ "minHeight": "0",
1445
+ "display": "flex",
1446
+ "flexDirection": "column",
1447
+ "overflow": "hidden"
1448
+ },
1449
+ {"display": "none"} # Hide terminal button on canvas view
1450
+ )
1451
+ else:
1452
+ # Show files, hide canvas, show terminal button
1453
+ return (
1454
+ {
1455
+ "flex": "1",
1456
+ "minHeight": "0",
1457
+ "display": "flex",
1458
+ "flexDirection": "column",
1459
+ "paddingBottom": "5%"
1460
+ },
1461
+ {
1462
+ "flex": "1",
1463
+ "minHeight": "0",
1464
+ "display": "none",
1465
+ "flexDirection": "column",
1466
+ "overflow": "hidden"
1467
+ },
1468
+ {} # Show terminal button (default styles)
1469
+ )
1470
+
1471
+
1472
+ # Canvas content update
1473
+ @app.callback(
1474
+ Output("canvas-content", "children"),
1475
+ [Input("poll-interval", "n_intervals"),
1476
+ Input("sidebar-view-toggle", "value")],
1477
+ [State("theme-store", "data")],
1478
+ prevent_initial_call=False
1479
+ )
1480
+ def update_canvas_content(n_intervals, view_value, theme):
1481
+ """Update canvas content from agent state."""
1482
+ state = get_agent_state()
1483
+ canvas_items = state.get("canvas", [])
1484
+ colors = get_colors(theme or "light")
1485
+
1486
+ # Use imported rendering function
1487
+ return render_canvas_items(canvas_items, colors)
1488
+
1489
+
1490
+
1491
+ # Clear canvas callback
1492
+ @app.callback(
1493
+ Output("canvas-content", "children", allow_duplicate=True),
1494
+ Input("clear-canvas-btn", "n_clicks"),
1495
+ [State("theme-store", "data")],
1496
+ prevent_initial_call=True
1497
+ )
1498
+ def clear_canvas(n_clicks, theme):
1499
+ """Clear the canvas and archive the .canvas folder with a timestamp."""
1500
+ if not n_clicks:
1501
+ raise PreventUpdate
1502
+
1503
+ global _agent_state
1504
+ colors = get_colors(theme or "light")
1505
+
1506
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1507
+
1508
+ # Archive .canvas folder if it exists (contains canvas.md and all assets)
1509
+ canvas_dir = WORKSPACE_ROOT / ".canvas"
1510
+ if canvas_dir.exists() and canvas_dir.is_dir():
1511
+ try:
1512
+ archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
1513
+ shutil.move(str(canvas_dir), str(archive_dir))
1514
+ print(f"Archived .canvas folder to {archive_dir}")
1515
+ except Exception as e:
1516
+ print(f"Failed to archive .canvas folder: {e}")
1517
+
1518
+ # Clear canvas in state
1519
+ with _agent_state_lock:
1520
+ _agent_state["canvas"] = []
1521
+
1522
+ # Return empty state
1523
+ return html.Div([
1524
+ html.Div("🗒", style={
1525
+ "fontSize": "48px",
1526
+ "textAlign": "center",
1527
+ "marginBottom": "16px",
1528
+ "opacity": "0.3"
1529
+ }),
1530
+ html.P("Canvas is empty", style={
1531
+ "textAlign": "center",
1532
+ "color": colors["text_muted"],
1533
+ "fontSize": "14px"
1534
+ }),
1535
+ html.P("The agent will add visualizations, charts, and notes here", style={
1536
+ "textAlign": "center",
1537
+ "color": colors["text_muted"],
1538
+ "fontSize": "12px",
1539
+ "marginTop": "8px"
1540
+ })
1541
+ ], style={
1542
+ "display": "flex",
1543
+ "flexDirection": "column",
1544
+ "alignItems": "center",
1545
+ "justifyContent": "center",
1546
+ "height": "100%",
1547
+ "padding": "40px"
1548
+ })
1549
+
1550
+
1551
+ # =============================================================================
1552
+ # THEME TOGGLE CALLBACK - Using DMC 2.4 forceColorScheme
1553
+ # =============================================================================
1554
+
1555
+ @app.callback(
1556
+ [Output("theme-store", "data"),
1557
+ Output("mantine-provider", "forceColorScheme"),
1558
+ Output("theme-toggle-btn", "children")],
1559
+ [Input("theme-toggle-btn", "n_clicks")],
1560
+ [State("theme-store", "data")],
1561
+ prevent_initial_call=True
1562
+ )
1563
+ def toggle_theme(n_clicks, current_theme):
1564
+ """Toggle between light and dark theme using DMC's forceColorScheme."""
1565
+ if not n_clicks:
1566
+ raise PreventUpdate
1567
+
1568
+ # Toggle theme
1569
+ new_theme = "dark" if current_theme == "light" else "light"
1570
+
1571
+ # Update the icon
1572
+ toggle_icon = DashIconify(
1573
+ icon="radix-icons:sun" if new_theme == "dark" else "radix-icons:moon",
1574
+ width=18
1575
+ )
1576
+
1577
+ return new_theme, new_theme, toggle_icon
1578
+
1579
+
1580
+ # Callback to initialize theme on page load
1581
+ @app.callback(
1582
+ [Output("mantine-provider", "forceColorScheme", allow_duplicate=True),
1583
+ Output("theme-toggle-btn", "children", allow_duplicate=True)],
1584
+ [Input("theme-store", "data")],
1585
+ prevent_initial_call='initial_duplicate'
1586
+ )
1587
+ def initialize_theme(theme):
1588
+ """Initialize theme on page load from stored preference."""
1589
+ if not theme:
1590
+ theme = "light"
1591
+
1592
+ toggle_icon = DashIconify(
1593
+ icon="radix-icons:sun" if theme == "dark" else "radix-icons:moon",
1594
+ width=18
1595
+ )
1596
+
1597
+ return theme, toggle_icon
1598
+
1599
+
1600
+ # =============================================================================
1601
+ # PROGRAMMATIC API
1602
+ # =============================================================================
1603
+
1604
+ def run_app(
1605
+ agent_instance=None,
1606
+ workspace=None,
1607
+ agent_spec=None,
1608
+ port=None,
1609
+ host=None,
1610
+ debug=None,
1611
+ title=None,
1612
+ subtitle=None,
1613
+ config_file=None
1614
+ ):
1615
+ """
1616
+ Run DeepAgent Dash programmatically.
1617
+
1618
+ This function can be called from Python code or used as the entry point
1619
+ for the CLI. It handles configuration loading and overrides.
1620
+
1621
+ Args:
1622
+ agent_instance (object, optional): Agent object instance (Python API only)
1623
+ workspace (str, optional): Workspace directory path
1624
+ agent_spec (str, optional): Agent specification as "path:object" (overrides agent_instance)
1625
+ port (int, optional): Port number
1626
+ host (str, optional): Host to bind to
1627
+ debug (bool, optional): Debug mode
1628
+ title (str, optional): Application title
1629
+ subtitle (str, optional): Application subtitle
1630
+ config_file (str, optional): Path to config file (default: ./config.py)
1631
+
1632
+ Returns:
1633
+ int: Exit code (0 for success, non-zero for error)
1634
+
1635
+ Examples:
1636
+ >>> # Using agent instance directly
1637
+ >>> from cowork_dash import run_app
1638
+ >>> my_agent = MyAgent()
1639
+ >>> run_app(my_agent, workspace="~/my-workspace")
1640
+
1641
+ >>> # Using agent spec
1642
+ >>> run_app(agent_spec="my_agent.py:agent", port=8080)
1643
+
1644
+ >>> # Without agent (manual mode)
1645
+ >>> run_app(workspace="~/my-workspace", debug=True)
1646
+ """
1647
+ global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, agent, AGENT_ERROR, args
1648
+
1649
+ # Load config file if specified and exists
1650
+ config_module = None
1651
+ if config_file:
1652
+ config_path = Path(config_file).resolve()
1653
+ if config_path.exists():
1654
+ import importlib.util
1655
+ spec = importlib.util.spec_from_file_location("user_config", config_path)
1656
+ if spec and spec.loader:
1657
+ config_module = importlib.util.module_from_spec(spec)
1658
+ spec.loader.exec_module(config_module)
1659
+ print(f"✓ Loaded config from {config_path}")
1660
+ else:
1661
+ print(f"⚠️ Config file not found: {config_path}, using defaults")
1662
+
1663
+ # Apply configuration with overrides
1664
+ if config_module:
1665
+ # Use config file values as base
1666
+ WORKSPACE_ROOT = Path(workspace).resolve() if workspace else getattr(config_module, "WORKSPACE_ROOT", config.WORKSPACE_ROOT)
1667
+ APP_TITLE = title if title else getattr(config_module, "APP_TITLE", config.APP_TITLE)
1668
+ APP_SUBTITLE = subtitle if subtitle else getattr(config_module, "APP_SUBTITLE", config.APP_SUBTITLE)
1669
+ PORT = port if port is not None else getattr(config_module, "PORT", config.PORT)
1670
+ HOST = host if host else getattr(config_module, "HOST", config.HOST)
1671
+ DEBUG = debug if debug is not None else getattr(config_module, "DEBUG", config.DEBUG)
1672
+
1673
+ # Agent priority: agent_spec > agent_instance > config file
1674
+ if agent_spec:
1675
+ # Load agent from spec (highest priority)
1676
+ agent, AGENT_ERROR = load_agent_from_spec(agent_spec)
1677
+ elif agent_instance is not None:
1678
+ # Use provided agent instance
1679
+ agent = agent_instance
1680
+ AGENT_ERROR = None
1681
+ else:
1682
+ # Get agent from config file
1683
+ get_agent_func = getattr(config_module, "get_agent", None)
1684
+ if get_agent_func:
1685
+ result = get_agent_func()
1686
+ if isinstance(result, tuple):
1687
+ agent, AGENT_ERROR = result
1688
+ else:
1689
+ agent = result
1690
+ AGENT_ERROR = None
1691
+ else:
1692
+ agent = None
1693
+ AGENT_ERROR = "No get_agent() function in config file"
1694
+ else:
1695
+ # No config file, use CLI args or defaults
1696
+ WORKSPACE_ROOT = Path(workspace).resolve() if workspace else config.WORKSPACE_ROOT
1697
+ APP_TITLE = title if title else config.APP_TITLE
1698
+ APP_SUBTITLE = subtitle if subtitle else config.APP_SUBTITLE
1699
+ PORT = port if port is not None else config.PORT
1700
+ HOST = host if host else config.HOST
1701
+ DEBUG = debug if debug is not None else config.DEBUG
1702
+
1703
+ # Agent priority: agent_spec > agent_instance > config default
1704
+ if agent_spec:
1705
+ # Load agent from spec (highest priority)
1706
+ agent, AGENT_ERROR = load_agent_from_spec(agent_spec)
1707
+ elif agent_instance is not None:
1708
+ # Use provided agent instance
1709
+ agent = agent_instance
1710
+ AGENT_ERROR = None
1711
+ else:
1712
+ # Use default config agent
1713
+ agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
1714
+
1715
+ # Ensure workspace exists
1716
+ WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
1717
+
1718
+ # Set environment variable for agent to access workspace
1719
+ # This allows user agents to read DEEPAGENT_WORKSPACE_ROOT
1720
+ os.environ['DEEPAGENT_WORKSPACE_ROOT'] = str(WORKSPACE_ROOT)
1721
+
1722
+ # Update global state to use the configured workspace
1723
+ global _agent_state
1724
+ _agent_state["canvas"] = load_canvas_from_markdown(WORKSPACE_ROOT)
1725
+
1726
+ # Create a mock args object for compatibility with existing code
1727
+ class Args:
1728
+ pass
1729
+ args = Args()
1730
+ args.workspace = workspace
1731
+ args.agent = agent_spec
1732
+
1733
+ # Print startup banner
1734
+ print("\n" + "="*50)
1735
+ print(f" {APP_TITLE}")
1736
+ print("="*50)
1737
+ print(f" Workspace: {WORKSPACE_ROOT}")
1738
+ if workspace:
1739
+ print(f" (from CLI: --workspace {workspace})")
1740
+ print(f" Agent: {'Ready' if agent else 'Not available'}")
1741
+ if agent_spec:
1742
+ print(f" (from CLI: --agent {agent_spec})")
1743
+ if AGENT_ERROR:
1744
+ print(f" Error: {AGENT_ERROR}")
1745
+ print(f" URL: http://{HOST}:{PORT}")
1746
+ print(f" Debug: {DEBUG}")
1747
+ print("="*50 + "\n")
1748
+
1749
+ # Run the app
1750
+ try:
1751
+ app.run(debug=DEBUG, host=HOST, port=PORT)
1752
+ return 0
1753
+ except Exception as e:
1754
+ print(f"\n❌ Error running app: {e}")
1755
+ return 1
1756
+
1757
+
1758
+ # =============================================================================
1759
+ # MAIN - BACKWARDS COMPATIBILITY
1760
+ # =============================================================================
1761
+
1762
+ if __name__ == "__main__":
1763
+ # Parse CLI arguments
1764
+ args = parse_args()
1765
+
1766
+ # When run directly (not as package), use original CLI arg parsing
1767
+ sys.exit(run_app(
1768
+ workspace=args.workspace if args.workspace else None,
1769
+ agent_spec=args.agent if args.agent else None,
1770
+ port=args.port if args.port else None,
1771
+ host=args.host if args.host else None,
1772
+ debug=args.debug if args.debug else (not args.no_debug if args.no_debug else None),
1773
+ title=args.title if args.title else None,
1774
+ subtitle=args.subtitle if args.subtitle else None,
1775
+ config_file=args.config if args.config else None
1776
+ ))