cowork-dash 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cowork_dash/agent.py +65 -24
- cowork_dash/app.py +602 -333
- cowork_dash/assets/styles.css +12 -0
- cowork_dash/backends.py +435 -0
- cowork_dash/canvas.py +96 -37
- cowork_dash/cli.py +23 -12
- cowork_dash/components.py +0 -1
- cowork_dash/config.py +21 -0
- cowork_dash/file_utils.py +147 -18
- cowork_dash/layout.py +9 -2
- cowork_dash/tools.py +206 -15
- cowork_dash/virtual_fs.py +468 -0
- {cowork_dash-0.1.6.dist-info → cowork_dash-0.1.8.dist-info}/METADATA +1 -1
- cowork_dash-0.1.8.dist-info/RECORD +22 -0
- cowork_dash-0.1.6.dist-info/RECORD +0 -20
- {cowork_dash-0.1.6.dist-info → cowork_dash-0.1.8.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.6.dist-info → cowork_dash-0.1.8.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.6.dist-info → cowork_dash-0.1.8.dist-info}/licenses/LICENSE +0 -0
cowork_dash/app.py
CHANGED
|
@@ -10,7 +10,6 @@ import platform
|
|
|
10
10
|
import subprocess
|
|
11
11
|
import threading
|
|
12
12
|
import time
|
|
13
|
-
import argparse
|
|
14
13
|
import importlib.util
|
|
15
14
|
from pathlib import Path
|
|
16
15
|
from datetime import datetime
|
|
@@ -18,20 +17,20 @@ from typing import Optional, Dict, Any, List
|
|
|
18
17
|
from dotenv import load_dotenv
|
|
19
18
|
load_dotenv()
|
|
20
19
|
|
|
21
|
-
from dash import Dash, html, Input, Output, State, callback_context, no_update, ALL
|
|
20
|
+
from dash import Dash, html, Input, Output, State, callback_context, no_update, ALL
|
|
22
21
|
from dash.exceptions import PreventUpdate
|
|
23
22
|
import dash_mantine_components as dmc
|
|
24
23
|
from dash_iconify import DashIconify
|
|
25
24
|
|
|
26
25
|
# Import custom modules
|
|
27
|
-
from .canvas import
|
|
26
|
+
from .canvas import export_canvas_to_markdown, load_canvas_from_markdown
|
|
28
27
|
from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
|
|
29
28
|
from .components import (
|
|
30
|
-
format_message, format_loading, format_thinking,
|
|
31
|
-
format_todos_inline, render_canvas_items, format_tool_calls_inline,
|
|
29
|
+
format_message, format_loading, format_thinking, format_todos_inline, render_canvas_items, format_tool_calls_inline,
|
|
32
30
|
format_interrupt
|
|
33
31
|
)
|
|
34
32
|
from .layout import create_layout as create_layout_component
|
|
33
|
+
from .virtual_fs import get_session_manager
|
|
35
34
|
|
|
36
35
|
# Import configuration defaults
|
|
37
36
|
from . import config
|
|
@@ -39,88 +38,6 @@ from . import config
|
|
|
39
38
|
# Generate thread ID
|
|
40
39
|
thread_id = str(uuid.uuid4())
|
|
41
40
|
|
|
42
|
-
# Parse command-line arguments early
|
|
43
|
-
def parse_args():
|
|
44
|
-
"""Parse command-line arguments."""
|
|
45
|
-
parser = argparse.ArgumentParser(
|
|
46
|
-
description="FastDash Browser - AI Agent Web Interface",
|
|
47
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
48
|
-
epilog="""
|
|
49
|
-
Examples:
|
|
50
|
-
# Use defaults from config.py
|
|
51
|
-
python app.py
|
|
52
|
-
|
|
53
|
-
# Override workspace and port
|
|
54
|
-
python app.py --workspace ~/my-workspace --port 8080
|
|
55
|
-
|
|
56
|
-
# Use custom agent from file
|
|
57
|
-
python app.py --agent my_agents.py:my_agent
|
|
58
|
-
|
|
59
|
-
# Production mode
|
|
60
|
-
python app.py --host 0.0.0.0 --port 80 --no-debug
|
|
61
|
-
|
|
62
|
-
# Debug mode with custom workspace
|
|
63
|
-
python app.py --debug --workspace /tmp/test-workspace
|
|
64
|
-
"""
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
parser.add_argument(
|
|
68
|
-
"--workspace",
|
|
69
|
-
type=str,
|
|
70
|
-
help="Workspace directory path (default: from config.py)"
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
parser.add_argument(
|
|
74
|
-
"--agent",
|
|
75
|
-
type=str,
|
|
76
|
-
metavar="PATH:OBJECT",
|
|
77
|
-
help='Agent specification as "path/to/file.py:object_name" (e.g., "agent.py:agent" or "my_agents.py:custom_agent")'
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
parser.add_argument(
|
|
81
|
-
"--port",
|
|
82
|
-
type=int,
|
|
83
|
-
help="Port to run on (default: from config.py)"
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
parser.add_argument(
|
|
87
|
-
"--host",
|
|
88
|
-
type=str,
|
|
89
|
-
help="Host to bind to (default: from config.py)"
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
parser.add_argument(
|
|
93
|
-
"--debug",
|
|
94
|
-
action="store_true",
|
|
95
|
-
help="Enable debug mode"
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
parser.add_argument(
|
|
99
|
-
"--no-debug",
|
|
100
|
-
action="store_true",
|
|
101
|
-
help="Disable debug mode"
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
parser.add_argument(
|
|
105
|
-
"--title",
|
|
106
|
-
type=str,
|
|
107
|
-
help="Application title (default: from config.py)"
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
parser.add_argument(
|
|
111
|
-
"--subtitle",
|
|
112
|
-
type=str,
|
|
113
|
-
help="Application subtitle (default: from config.py)"
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
parser.add_argument(
|
|
117
|
-
"--config",
|
|
118
|
-
type=str,
|
|
119
|
-
help="Path to configuration file (default: config.py)"
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
return parser.parse_args()
|
|
123
|
-
|
|
124
41
|
def load_agent_from_spec(agent_spec: str):
|
|
125
42
|
"""
|
|
126
43
|
Load agent from specification string.
|
|
@@ -203,14 +120,56 @@ PORT = config.PORT
|
|
|
203
120
|
HOST = config.HOST
|
|
204
121
|
DEBUG = config.DEBUG
|
|
205
122
|
WELCOME_MESSAGE = config.WELCOME_MESSAGE
|
|
123
|
+
USE_VIRTUAL_FS = config.VIRTUAL_FS # Can be overridden by --virtual-fs CLI arg
|
|
206
124
|
|
|
207
|
-
# Ensure workspace exists
|
|
208
|
-
|
|
125
|
+
# Ensure workspace exists (only for physical filesystem mode)
|
|
126
|
+
if not USE_VIRTUAL_FS:
|
|
127
|
+
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
209
128
|
|
|
210
129
|
# Initialize agent from config
|
|
211
130
|
agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
|
|
212
131
|
|
|
213
132
|
|
|
133
|
+
def get_workspace_for_session(session_id: Optional[str] = None):
|
|
134
|
+
"""Get the workspace root for a session.
|
|
135
|
+
|
|
136
|
+
In virtual filesystem mode, returns a VirtualFilesystem for the session.
|
|
137
|
+
In physical mode, returns the WORKSPACE_ROOT Path.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
session_id: Session ID (required for virtual FS mode, ignored otherwise)
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Path or VirtualFilesystem depending on USE_VIRTUAL_FS setting
|
|
144
|
+
"""
|
|
145
|
+
if USE_VIRTUAL_FS:
|
|
146
|
+
if not session_id:
|
|
147
|
+
# Generate a new session if none provided
|
|
148
|
+
session_id = get_session_manager().create_session()
|
|
149
|
+
else:
|
|
150
|
+
# Get or create session
|
|
151
|
+
session_id = get_session_manager().get_or_create_session(session_id)
|
|
152
|
+
return get_session_manager().get_filesystem(session_id)
|
|
153
|
+
else:
|
|
154
|
+
return WORKSPACE_ROOT
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_or_create_session_id(existing_id: Optional[str] = None) -> str:
|
|
158
|
+
"""Get existing session ID or create a new one.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
existing_id: Existing session ID from cookie/store
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Valid session ID
|
|
165
|
+
"""
|
|
166
|
+
if USE_VIRTUAL_FS:
|
|
167
|
+
return get_session_manager().get_or_create_session(existing_id)
|
|
168
|
+
else:
|
|
169
|
+
# In physical mode, still track session IDs but they all share the same workspace
|
|
170
|
+
return existing_id or str(uuid.uuid4())
|
|
171
|
+
|
|
172
|
+
|
|
214
173
|
# =============================================================================
|
|
215
174
|
# STYLING
|
|
216
175
|
# =============================================================================
|
|
@@ -278,13 +237,13 @@ def get_colors(theme: str = "light") -> dict:
|
|
|
278
237
|
# AGENT INTERACTION - WITH REAL-TIME STREAMING
|
|
279
238
|
# =============================================================================
|
|
280
239
|
|
|
281
|
-
# Global state for streaming updates
|
|
240
|
+
# Global state for streaming updates (used in physical FS mode)
|
|
282
241
|
_agent_state = {
|
|
283
242
|
"running": False,
|
|
284
243
|
"thinking": "",
|
|
285
244
|
"todos": [],
|
|
286
245
|
"tool_calls": [], # Current turn's tool calls (reset each turn)
|
|
287
|
-
"canvas": load_canvas_from_markdown(WORKSPACE_ROOT), # Load from canvas.md if exists
|
|
246
|
+
"canvas": load_canvas_from_markdown(WORKSPACE_ROOT) if not USE_VIRTUAL_FS else [], # Load from canvas.md if exists (physical FS only)
|
|
288
247
|
"response": "",
|
|
289
248
|
"error": None,
|
|
290
249
|
"interrupt": None, # Track interrupt requests for human-in-the-loop
|
|
@@ -294,25 +253,110 @@ _agent_state = {
|
|
|
294
253
|
}
|
|
295
254
|
_agent_state_lock = threading.Lock()
|
|
296
255
|
|
|
256
|
+
# Session-aware state for virtual FS mode
|
|
257
|
+
# Each session gets its own agent instance and state
|
|
258
|
+
_session_agents: Dict[str, Any] = {}
|
|
259
|
+
_session_agent_states: Dict[str, Dict[str, Any]] = {}
|
|
260
|
+
_session_agents_lock = threading.Lock()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _get_default_agent_state() -> Dict[str, Any]:
|
|
264
|
+
"""Return a fresh default agent state dict."""
|
|
265
|
+
return {
|
|
266
|
+
"running": False,
|
|
267
|
+
"thinking": "",
|
|
268
|
+
"todos": [],
|
|
269
|
+
"tool_calls": [],
|
|
270
|
+
"canvas": [],
|
|
271
|
+
"response": "",
|
|
272
|
+
"error": None,
|
|
273
|
+
"interrupt": None,
|
|
274
|
+
"last_update": time.time(),
|
|
275
|
+
"start_time": None,
|
|
276
|
+
"stop_requested": False,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _get_session_agent(session_id: str):
|
|
281
|
+
"""Get or create agent for a session (virtual FS mode only).
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
session_id: The session ID.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
The agent instance for this session.
|
|
288
|
+
"""
|
|
289
|
+
from .agent import create_session_agent
|
|
290
|
+
|
|
291
|
+
with _session_agents_lock:
|
|
292
|
+
if session_id not in _session_agents:
|
|
293
|
+
_session_agents[session_id] = create_session_agent(session_id)
|
|
294
|
+
return _session_agents[session_id]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _get_session_state(session_id: str) -> Dict[str, Any]:
|
|
298
|
+
"""Get or create agent state for a session (virtual FS mode only).
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
session_id: The session ID.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The agent state dict for this session.
|
|
305
|
+
"""
|
|
306
|
+
with _session_agents_lock:
|
|
307
|
+
if session_id not in _session_agent_states:
|
|
308
|
+
_session_agent_states[session_id] = _get_default_agent_state()
|
|
309
|
+
return _session_agent_states[session_id]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _get_session_state_lock() -> threading.Lock:
|
|
313
|
+
"""Get the lock for session state access."""
|
|
314
|
+
return _session_agents_lock
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def request_agent_stop(session_id: Optional[str] = None):
|
|
318
|
+
"""Request the agent to stop execution.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
session_id: Session ID for virtual FS mode, None for physical FS mode.
|
|
322
|
+
"""
|
|
323
|
+
if USE_VIRTUAL_FS and session_id:
|
|
324
|
+
state = _get_session_state(session_id)
|
|
325
|
+
with _session_agents_lock:
|
|
326
|
+
state["stop_requested"] = True
|
|
327
|
+
state["last_update"] = time.time()
|
|
328
|
+
else:
|
|
329
|
+
with _agent_state_lock:
|
|
330
|
+
_agent_state["stop_requested"] = True
|
|
331
|
+
_agent_state["last_update"] = time.time()
|
|
297
332
|
|
|
298
|
-
def request_agent_stop():
|
|
299
|
-
"""Request the agent to stop execution."""
|
|
300
|
-
with _agent_state_lock:
|
|
301
|
-
_agent_state["stop_requested"] = True
|
|
302
|
-
_agent_state["last_update"] = time.time()
|
|
303
333
|
|
|
304
|
-
def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None):
|
|
305
|
-
"""Run agent in background thread and update
|
|
334
|
+
def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: str = None, session_id: Optional[str] = None):
|
|
335
|
+
"""Run agent in background thread and update state in real-time.
|
|
306
336
|
|
|
307
337
|
Args:
|
|
308
338
|
message: User message to send to agent
|
|
309
339
|
resume_data: Optional dict with 'decisions' to resume from interrupt
|
|
310
340
|
workspace_path: Current workspace directory path to inject into agent context
|
|
341
|
+
session_id: Session ID for virtual FS mode (determines which agent and state to use)
|
|
311
342
|
"""
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
343
|
+
# Determine which agent and state to use based on mode
|
|
344
|
+
if USE_VIRTUAL_FS and session_id:
|
|
345
|
+
current_agent = _get_session_agent(session_id)
|
|
346
|
+
current_state = _get_session_state(session_id)
|
|
347
|
+
state_lock = _session_agents_lock
|
|
348
|
+
# Use session_id as thread_id for LangGraph checkpointing
|
|
349
|
+
current_thread_id = session_id
|
|
350
|
+
else:
|
|
351
|
+
current_agent = agent
|
|
352
|
+
current_state = _agent_state
|
|
353
|
+
state_lock = _agent_state_lock
|
|
354
|
+
current_thread_id = thread_id
|
|
355
|
+
|
|
356
|
+
if not current_agent:
|
|
357
|
+
with state_lock:
|
|
358
|
+
current_state["response"] = f"⚠️ {current_state.get('error', 'No agent available')}\n\nPlease check your setup and try again."
|
|
359
|
+
current_state["running"] = False
|
|
316
360
|
return
|
|
317
361
|
|
|
318
362
|
# Track tool calls by their ID for updating status
|
|
@@ -339,17 +383,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
339
383
|
|
|
340
384
|
def _update_tool_call_result(tool_call_id: str, result: Any, status: str = "success"):
|
|
341
385
|
"""Update a tool call with its result."""
|
|
342
|
-
with
|
|
343
|
-
for tc in
|
|
386
|
+
with state_lock:
|
|
387
|
+
for tc in current_state["tool_calls"]:
|
|
344
388
|
if tc.get("id") == tool_call_id:
|
|
345
389
|
tc["result"] = result
|
|
346
390
|
tc["status"] = status
|
|
347
391
|
break
|
|
348
|
-
|
|
392
|
+
current_state["last_update"] = time.time()
|
|
393
|
+
|
|
394
|
+
# Set tool session context for virtual FS mode
|
|
395
|
+
# This allows tools like add_to_canvas to access the session's VirtualFilesystem
|
|
396
|
+
from .tools import set_tool_session_context, clear_tool_session_context
|
|
397
|
+
if USE_VIRTUAL_FS and session_id:
|
|
398
|
+
set_tool_session_context(session_id)
|
|
349
399
|
|
|
350
400
|
try:
|
|
351
401
|
# Prepare input based on whether we're resuming or starting fresh
|
|
352
|
-
stream_config = dict(configurable=dict(thread_id=
|
|
402
|
+
stream_config = dict(configurable=dict(thread_id=current_thread_id))
|
|
353
403
|
|
|
354
404
|
if message == "__RESUME__":
|
|
355
405
|
# Resume from interrupt
|
|
@@ -364,24 +414,24 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
364
414
|
message_with_context = message
|
|
365
415
|
agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
|
|
366
416
|
|
|
367
|
-
for update in
|
|
417
|
+
for update in current_agent.stream(agent_input, stream_mode="updates", config=stream_config):
|
|
368
418
|
# Check if stop was requested
|
|
369
|
-
with
|
|
370
|
-
if
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
419
|
+
with state_lock:
|
|
420
|
+
if current_state.get("stop_requested"):
|
|
421
|
+
current_state["response"] = current_state.get("response", "") + "\n\nAgent stopped by user."
|
|
422
|
+
current_state["running"] = False
|
|
423
|
+
current_state["stop_requested"] = False
|
|
424
|
+
current_state["last_update"] = time.time()
|
|
375
425
|
return
|
|
376
426
|
|
|
377
427
|
# Check for interrupt
|
|
378
428
|
if isinstance(update, dict) and "__interrupt__" in update:
|
|
379
429
|
interrupt_value = update["__interrupt__"]
|
|
380
430
|
interrupt_data = _process_interrupt(interrupt_value)
|
|
381
|
-
with
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
431
|
+
with state_lock:
|
|
432
|
+
current_state["interrupt"] = interrupt_data
|
|
433
|
+
current_state["running"] = False # Pause until user responds
|
|
434
|
+
current_state["last_update"] = time.time()
|
|
385
435
|
return # Exit stream, wait for user to resume
|
|
386
436
|
|
|
387
437
|
if isinstance(update, dict):
|
|
@@ -400,9 +450,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
400
450
|
tool_call_map[serialized["id"]] = serialized
|
|
401
451
|
new_tool_calls.append(serialized)
|
|
402
452
|
|
|
403
|
-
with
|
|
404
|
-
|
|
405
|
-
|
|
453
|
+
with state_lock:
|
|
454
|
+
current_state["tool_calls"].extend(new_tool_calls)
|
|
455
|
+
current_state["last_update"] = time.time()
|
|
406
456
|
|
|
407
457
|
elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
|
|
408
458
|
# Update tool call status when we get the result
|
|
@@ -451,9 +501,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
451
501
|
thinking_text = content.get('reflection', str(content))
|
|
452
502
|
|
|
453
503
|
# Update state immediately
|
|
454
|
-
with
|
|
455
|
-
|
|
456
|
-
|
|
504
|
+
with state_lock:
|
|
505
|
+
current_state["thinking"] = thinking_text
|
|
506
|
+
current_state["last_update"] = time.time()
|
|
457
507
|
|
|
458
508
|
elif last_msg.name == 'write_todos':
|
|
459
509
|
content = last_msg.content
|
|
@@ -473,9 +523,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
473
523
|
todos = content
|
|
474
524
|
|
|
475
525
|
# Update state immediately
|
|
476
|
-
with
|
|
477
|
-
|
|
478
|
-
|
|
526
|
+
with state_lock:
|
|
527
|
+
current_state["todos"] = todos
|
|
528
|
+
current_state["last_update"] = time.time()
|
|
479
529
|
|
|
480
530
|
elif last_msg.name == 'add_to_canvas':
|
|
481
531
|
content = last_msg.content
|
|
@@ -493,15 +543,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
493
543
|
canvas_item = {"type": "markdown", "data": str(content)}
|
|
494
544
|
|
|
495
545
|
# Update state immediately - append to canvas
|
|
496
|
-
with
|
|
497
|
-
|
|
498
|
-
|
|
546
|
+
with state_lock:
|
|
547
|
+
current_state["canvas"].append(canvas_item)
|
|
548
|
+
current_state["last_update"] = time.time()
|
|
499
549
|
|
|
500
|
-
# Also export to markdown file
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
550
|
+
# Also export to markdown file (physical FS only)
|
|
551
|
+
if not USE_VIRTUAL_FS:
|
|
552
|
+
try:
|
|
553
|
+
export_canvas_to_markdown(current_state["canvas"], WORKSPACE_ROOT)
|
|
554
|
+
except Exception as e:
|
|
555
|
+
print(f"Failed to export canvas: {e}")
|
|
505
556
|
|
|
506
557
|
elif last_msg.name == 'update_canvas_item':
|
|
507
558
|
content = last_msg.content
|
|
@@ -518,22 +569,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
518
569
|
|
|
519
570
|
item_id = canvas_item.get("id")
|
|
520
571
|
if item_id:
|
|
521
|
-
with
|
|
572
|
+
with state_lock:
|
|
522
573
|
# Find and replace the item with matching ID
|
|
523
|
-
for i, existing in enumerate(
|
|
574
|
+
for i, existing in enumerate(current_state["canvas"]):
|
|
524
575
|
if existing.get("id") == item_id:
|
|
525
|
-
|
|
576
|
+
current_state["canvas"][i] = canvas_item
|
|
526
577
|
break
|
|
527
578
|
else:
|
|
528
579
|
# If not found, append as new item
|
|
529
|
-
|
|
530
|
-
|
|
580
|
+
current_state["canvas"].append(canvas_item)
|
|
581
|
+
current_state["last_update"] = time.time()
|
|
531
582
|
|
|
532
|
-
# Export to markdown file
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
583
|
+
# Export to markdown file (physical FS only)
|
|
584
|
+
if not USE_VIRTUAL_FS:
|
|
585
|
+
try:
|
|
586
|
+
export_canvas_to_markdown(current_state["canvas"], WORKSPACE_ROOT)
|
|
587
|
+
except Exception as e:
|
|
588
|
+
print(f"Failed to export canvas: {e}")
|
|
537
589
|
|
|
538
590
|
elif last_msg.name == 'remove_canvas_item':
|
|
539
591
|
content = last_msg.content
|
|
@@ -550,18 +602,19 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
550
602
|
item_id = None
|
|
551
603
|
|
|
552
604
|
if item_id:
|
|
553
|
-
with
|
|
554
|
-
|
|
555
|
-
item for item in
|
|
605
|
+
with state_lock:
|
|
606
|
+
current_state["canvas"] = [
|
|
607
|
+
item for item in current_state["canvas"]
|
|
556
608
|
if item.get("id") != item_id
|
|
557
609
|
]
|
|
558
|
-
|
|
610
|
+
current_state["last_update"] = time.time()
|
|
559
611
|
|
|
560
|
-
# Export to markdown file
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
612
|
+
# Export to markdown file (physical FS only)
|
|
613
|
+
if not USE_VIRTUAL_FS:
|
|
614
|
+
try:
|
|
615
|
+
export_canvas_to_markdown(current_state["canvas"], WORKSPACE_ROOT)
|
|
616
|
+
except Exception as e:
|
|
617
|
+
print(f"Failed to export canvas: {e}")
|
|
565
618
|
|
|
566
619
|
elif last_msg.name in ('execute_cell', 'execute_all_cells'):
|
|
567
620
|
# Extract canvas_items from cell execution results
|
|
@@ -590,17 +643,18 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
590
643
|
|
|
591
644
|
# Add any canvas items found
|
|
592
645
|
if canvas_items_to_add:
|
|
593
|
-
with
|
|
646
|
+
with state_lock:
|
|
594
647
|
for item in canvas_items_to_add:
|
|
595
648
|
if isinstance(item, dict) and item.get('type'):
|
|
596
|
-
|
|
597
|
-
|
|
649
|
+
current_state["canvas"].append(item)
|
|
650
|
+
current_state["last_update"] = time.time()
|
|
598
651
|
|
|
599
|
-
# Export to markdown file
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
652
|
+
# Export to markdown file (physical FS only)
|
|
653
|
+
if not USE_VIRTUAL_FS:
|
|
654
|
+
try:
|
|
655
|
+
export_canvas_to_markdown(current_state["canvas"], WORKSPACE_ROOT)
|
|
656
|
+
except Exception as e:
|
|
657
|
+
print(f"Failed to export canvas: {e}")
|
|
604
658
|
|
|
605
659
|
elif hasattr(last_msg, 'content'):
|
|
606
660
|
content = last_msg.content
|
|
@@ -618,19 +672,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
618
672
|
response_text = " ".join(text_parts).strip()
|
|
619
673
|
|
|
620
674
|
if response_text:
|
|
621
|
-
with
|
|
622
|
-
|
|
623
|
-
|
|
675
|
+
with state_lock:
|
|
676
|
+
current_state["response"] = response_text
|
|
677
|
+
current_state["last_update"] = time.time()
|
|
624
678
|
|
|
625
679
|
except Exception as e:
|
|
626
|
-
with
|
|
627
|
-
|
|
628
|
-
|
|
680
|
+
with state_lock:
|
|
681
|
+
current_state["error"] = str(e)
|
|
682
|
+
current_state["response"] = f"Error: {str(e)}"
|
|
629
683
|
|
|
630
684
|
finally:
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
685
|
+
# Clear tool session context
|
|
686
|
+
if USE_VIRTUAL_FS and session_id:
|
|
687
|
+
clear_tool_session_context()
|
|
688
|
+
|
|
689
|
+
with state_lock:
|
|
690
|
+
current_state["running"] = False
|
|
691
|
+
current_state["last_update"] = time.time()
|
|
634
692
|
|
|
635
693
|
|
|
636
694
|
def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
@@ -729,20 +787,29 @@ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
|
729
787
|
|
|
730
788
|
return interrupt_data
|
|
731
789
|
|
|
732
|
-
def call_agent(message: str, resume_data: Dict = None, workspace_path: str = None):
|
|
790
|
+
def call_agent(message: str, resume_data: Dict = None, workspace_path: str = None, session_id: Optional[str] = None):
|
|
733
791
|
"""Start agent execution in background thread.
|
|
734
792
|
|
|
735
793
|
Args:
|
|
736
794
|
message: User message to send to agent
|
|
737
795
|
resume_data: Optional dict with decisions to resume from interrupt
|
|
738
796
|
workspace_path: Current workspace directory path to inject into agent context
|
|
797
|
+
session_id: Session ID for virtual FS mode
|
|
739
798
|
"""
|
|
799
|
+
# Determine which state to use
|
|
800
|
+
if USE_VIRTUAL_FS and session_id:
|
|
801
|
+
current_state = _get_session_state(session_id)
|
|
802
|
+
state_lock = _session_agents_lock
|
|
803
|
+
else:
|
|
804
|
+
current_state = _agent_state
|
|
805
|
+
state_lock = _agent_state_lock
|
|
806
|
+
|
|
740
807
|
# Reset state but preserve canvas - do it all atomically
|
|
741
|
-
with
|
|
742
|
-
existing_canvas =
|
|
808
|
+
with state_lock:
|
|
809
|
+
existing_canvas = current_state.get("canvas", []).copy()
|
|
743
810
|
|
|
744
|
-
|
|
745
|
-
|
|
811
|
+
current_state.clear()
|
|
812
|
+
current_state.update({
|
|
746
813
|
"running": True,
|
|
747
814
|
"thinking": "",
|
|
748
815
|
"todos": [],
|
|
@@ -757,21 +824,30 @@ def call_agent(message: str, resume_data: Dict = None, workspace_path: str = Non
|
|
|
757
824
|
})
|
|
758
825
|
|
|
759
826
|
# Start background thread
|
|
760
|
-
thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data, workspace_path))
|
|
827
|
+
thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data, workspace_path, session_id))
|
|
761
828
|
thread.daemon = True
|
|
762
829
|
thread.start()
|
|
763
830
|
|
|
764
831
|
|
|
765
|
-
def resume_agent_from_interrupt(decision: str, action: str = "approve", action_requests: List[Dict] = None):
|
|
832
|
+
def resume_agent_from_interrupt(decision: str, action: str = "approve", action_requests: List[Dict] = None, session_id: Optional[str] = None):
|
|
766
833
|
"""Resume agent from an interrupt with the user's decision.
|
|
767
834
|
|
|
768
835
|
Args:
|
|
769
836
|
decision: User's response/decision text
|
|
770
837
|
action: One of 'approve', 'reject', 'edit'
|
|
771
838
|
action_requests: List of action requests from the interrupt (for edit mode)
|
|
839
|
+
session_id: Session ID for virtual FS mode
|
|
772
840
|
"""
|
|
773
|
-
|
|
774
|
-
|
|
841
|
+
# Determine which state to use
|
|
842
|
+
if USE_VIRTUAL_FS and session_id:
|
|
843
|
+
current_state = _get_session_state(session_id)
|
|
844
|
+
state_lock = _session_agents_lock
|
|
845
|
+
else:
|
|
846
|
+
current_state = _agent_state
|
|
847
|
+
state_lock = _agent_state_lock
|
|
848
|
+
|
|
849
|
+
with state_lock:
|
|
850
|
+
interrupt_data = current_state.get("interrupt")
|
|
775
851
|
if not interrupt_data:
|
|
776
852
|
return
|
|
777
853
|
|
|
@@ -780,16 +856,16 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
|
|
|
780
856
|
action_requests = interrupt_data.get("action_requests", [])
|
|
781
857
|
|
|
782
858
|
# Clear interrupt and set running, but preserve tool_calls and canvas
|
|
783
|
-
existing_tool_calls =
|
|
784
|
-
existing_canvas =
|
|
859
|
+
existing_tool_calls = current_state.get("tool_calls", []).copy()
|
|
860
|
+
existing_canvas = current_state.get("canvas", []).copy()
|
|
785
861
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
862
|
+
current_state["interrupt"] = None
|
|
863
|
+
current_state["running"] = True
|
|
864
|
+
current_state["response"] = "" # Clear any previous response
|
|
865
|
+
current_state["error"] = None # Clear any previous error
|
|
866
|
+
current_state["tool_calls"] = existing_tool_calls # Keep existing tool calls
|
|
867
|
+
current_state["canvas"] = existing_canvas # Keep canvas
|
|
868
|
+
current_state["last_update"] = time.time()
|
|
793
869
|
|
|
794
870
|
# Build decisions list in the format expected by deepagents HITL middleware
|
|
795
871
|
# Format: {"decisions": [{"type": "approve"}, {"type": "reject", "message": "..."}, ...]}
|
|
@@ -813,10 +889,10 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
|
|
|
813
889
|
tool_names = [ar.get("tool", "unknown") for ar in action_requests]
|
|
814
890
|
tool_info = f" ({', '.join(tool_names)})"
|
|
815
891
|
|
|
816
|
-
with
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
892
|
+
with state_lock:
|
|
893
|
+
current_state["running"] = False
|
|
894
|
+
current_state["response"] = f"Action rejected{tool_info}: {reject_message}"
|
|
895
|
+
current_state["last_update"] = time.time()
|
|
820
896
|
|
|
821
897
|
return # Don't resume the agent
|
|
822
898
|
else: # edit - provide edited action
|
|
@@ -846,41 +922,62 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
|
|
|
846
922
|
|
|
847
923
|
# Start background thread with resume value
|
|
848
924
|
# Pass a special marker to indicate this is a resume operation
|
|
849
|
-
thread = threading.Thread(target=_run_agent_stream, args=("__RESUME__", resume_value))
|
|
925
|
+
thread = threading.Thread(target=_run_agent_stream, args=("__RESUME__", resume_value, None, session_id))
|
|
850
926
|
thread.daemon = True
|
|
851
927
|
thread.start()
|
|
852
928
|
|
|
853
|
-
def get_agent_state() -> Dict[str, Any]:
|
|
929
|
+
def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
|
|
854
930
|
"""Get current agent state (thread-safe).
|
|
855
931
|
|
|
932
|
+
Args:
|
|
933
|
+
session_id: Session ID for virtual FS mode, None for physical FS mode.
|
|
934
|
+
|
|
856
935
|
Returns a deep copy of mutable collections to prevent race conditions.
|
|
857
936
|
"""
|
|
858
|
-
|
|
859
|
-
|
|
937
|
+
if USE_VIRTUAL_FS and session_id:
|
|
938
|
+
current_state = _get_session_state(session_id)
|
|
939
|
+
state_lock = _session_agents_lock
|
|
940
|
+
else:
|
|
941
|
+
current_state = _agent_state
|
|
942
|
+
state_lock = _agent_state_lock
|
|
943
|
+
|
|
944
|
+
with state_lock:
|
|
945
|
+
state = current_state.copy()
|
|
860
946
|
# Deep copy mutable collections to prevent race conditions during rendering
|
|
861
|
-
state["tool_calls"] = copy.deepcopy(
|
|
862
|
-
state["todos"] = copy.deepcopy(
|
|
863
|
-
state["canvas"] = copy.deepcopy(
|
|
947
|
+
state["tool_calls"] = copy.deepcopy(current_state["tool_calls"])
|
|
948
|
+
state["todos"] = copy.deepcopy(current_state["todos"])
|
|
949
|
+
state["canvas"] = copy.deepcopy(current_state["canvas"])
|
|
864
950
|
return state
|
|
865
951
|
|
|
866
|
-
|
|
952
|
+
|
|
953
|
+
def reset_agent_state(session_id: Optional[str] = None):
|
|
867
954
|
"""Reset agent state for a fresh session (thread-safe).
|
|
868
955
|
|
|
869
956
|
Called on page load to ensure clean state after browser refresh.
|
|
870
|
-
Preserves canvas items loaded from canvas.md.
|
|
957
|
+
Preserves canvas items loaded from canvas.md (physical FS only).
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
session_id: Session ID for virtual FS mode, None for physical FS mode.
|
|
871
961
|
"""
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
962
|
+
if USE_VIRTUAL_FS and session_id:
|
|
963
|
+
current_state = _get_session_state(session_id)
|
|
964
|
+
state_lock = _session_agents_lock
|
|
965
|
+
else:
|
|
966
|
+
current_state = _agent_state
|
|
967
|
+
state_lock = _agent_state_lock
|
|
968
|
+
|
|
969
|
+
with state_lock:
|
|
970
|
+
current_state["running"] = False
|
|
971
|
+
current_state["thinking"] = ""
|
|
972
|
+
current_state["todos"] = []
|
|
973
|
+
current_state["tool_calls"] = []
|
|
974
|
+
current_state["response"] = ""
|
|
975
|
+
current_state["error"] = None
|
|
976
|
+
current_state["interrupt"] = None
|
|
977
|
+
current_state["start_time"] = None
|
|
978
|
+
current_state["stop_requested"] = False
|
|
979
|
+
current_state["last_update"] = time.time()
|
|
980
|
+
# Note: canvas is preserved - it's loaded from canvas.md on startup (physical FS only)
|
|
884
981
|
|
|
885
982
|
# =============================================================================
|
|
886
983
|
# DASH APP
|
|
@@ -927,8 +1024,12 @@ def create_layout():
|
|
|
927
1024
|
title = getattr(agent, 'name', None) or APP_TITLE
|
|
928
1025
|
subtitle = getattr(agent, 'description', None) or APP_SUBTITLE
|
|
929
1026
|
|
|
1027
|
+
# In virtual FS mode, pass None for workspace_root to show empty tree initially
|
|
1028
|
+
# The file tree will be populated per-session via callbacks
|
|
1029
|
+
workspace_for_layout = None if USE_VIRTUAL_FS else WORKSPACE_ROOT
|
|
1030
|
+
|
|
930
1031
|
return create_layout_component(
|
|
931
|
-
workspace_root=
|
|
1032
|
+
workspace_root=workspace_for_layout,
|
|
932
1033
|
app_title=title,
|
|
933
1034
|
app_subtitle=subtitle,
|
|
934
1035
|
colors=COLORS,
|
|
@@ -951,30 +1052,35 @@ app.layout = create_layout
|
|
|
951
1052
|
@app.callback(
|
|
952
1053
|
[Output("chat-messages", "children"),
|
|
953
1054
|
Output("skip-history-render", "data", allow_duplicate=True),
|
|
954
|
-
Output("session-initialized", "data", allow_duplicate=True)
|
|
1055
|
+
Output("session-initialized", "data", allow_duplicate=True),
|
|
1056
|
+
Output("session-id", "data", allow_duplicate=True)],
|
|
955
1057
|
[Input("chat-history", "data")],
|
|
956
1058
|
[State("theme-store", "data"),
|
|
957
1059
|
State("skip-history-render", "data"),
|
|
958
|
-
State("session-initialized", "data")
|
|
959
|
-
|
|
1060
|
+
State("session-initialized", "data"),
|
|
1061
|
+
State("session-id", "data")],
|
|
1062
|
+
prevent_initial_call='initial_duplicate'
|
|
960
1063
|
)
|
|
961
|
-
def display_initial_messages(history, theme, skip_render, session_initialized):
|
|
1064
|
+
def display_initial_messages(history, theme, skip_render, session_initialized, session_id):
|
|
962
1065
|
"""Display initial welcome message or chat history.
|
|
963
1066
|
|
|
964
1067
|
On first call (page load), resets agent state for a fresh session.
|
|
965
1068
|
Skip rendering if skip_render flag is set - this prevents duplicate renders
|
|
966
1069
|
when poll_agent_updates already handles the rendering.
|
|
967
1070
|
"""
|
|
968
|
-
#
|
|
1071
|
+
# Initialize session on page load (first callback trigger)
|
|
1072
|
+
new_session_id = session_id
|
|
969
1073
|
if not session_initialized:
|
|
970
|
-
|
|
1074
|
+
# Create or validate session ID for virtual FS mode
|
|
1075
|
+
new_session_id = get_or_create_session_id(session_id)
|
|
1076
|
+
reset_agent_state(new_session_id)
|
|
971
1077
|
|
|
972
1078
|
# Skip if flag is set (poll_agent_updates already rendered)
|
|
973
1079
|
if skip_render:
|
|
974
|
-
return no_update, False, True # Reset skip flag, mark session initialized
|
|
1080
|
+
return no_update, False, True, new_session_id # Reset skip flag, mark session initialized
|
|
975
1081
|
|
|
976
1082
|
if not history:
|
|
977
|
-
return [], False, True
|
|
1083
|
+
return [], False, True, new_session_id
|
|
978
1084
|
|
|
979
1085
|
colors = get_colors(theme or "light")
|
|
980
1086
|
messages = []
|
|
@@ -991,7 +1097,41 @@ def display_initial_messages(history, theme, skip_render, session_initialized):
|
|
|
991
1097
|
todos_block = format_todos_inline(msg["todos"], colors)
|
|
992
1098
|
if todos_block:
|
|
993
1099
|
messages.append(todos_block)
|
|
994
|
-
return messages, False, True
|
|
1100
|
+
return messages, False, True, new_session_id
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
# Initialize file tree for virtual FS sessions
|
|
1104
|
+
@app.callback(
|
|
1105
|
+
Output("file-tree", "children", allow_duplicate=True),
|
|
1106
|
+
Input("session-initialized", "data"),
|
|
1107
|
+
[State("session-id", "data"),
|
|
1108
|
+
State("current-workspace-path", "data"),
|
|
1109
|
+
State("theme-store", "data")],
|
|
1110
|
+
prevent_initial_call=True
|
|
1111
|
+
)
|
|
1112
|
+
def initialize_file_tree_for_session(session_initialized, session_id, current_workspace, theme):
|
|
1113
|
+
"""Initialize file tree when a new session is created (virtual FS mode).
|
|
1114
|
+
|
|
1115
|
+
In virtual FS mode, the file tree starts empty. This callback populates it
|
|
1116
|
+
when the session is initialized, showing the default workspace structure.
|
|
1117
|
+
"""
|
|
1118
|
+
if not USE_VIRTUAL_FS:
|
|
1119
|
+
raise PreventUpdate
|
|
1120
|
+
|
|
1121
|
+
if not session_initialized or not session_id:
|
|
1122
|
+
raise PreventUpdate
|
|
1123
|
+
|
|
1124
|
+
colors = get_colors(theme or "light")
|
|
1125
|
+
|
|
1126
|
+
# Get workspace for this session
|
|
1127
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1128
|
+
|
|
1129
|
+
# Calculate current workspace directory
|
|
1130
|
+
current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
|
|
1131
|
+
|
|
1132
|
+
# Build and render file tree
|
|
1133
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
1134
|
+
|
|
995
1135
|
|
|
996
1136
|
# Chat callbacks
|
|
997
1137
|
@app.callback(
|
|
@@ -1005,10 +1145,11 @@ def display_initial_messages(history, theme, skip_render, session_initialized):
|
|
|
1005
1145
|
[State("chat-input", "value"),
|
|
1006
1146
|
State("chat-history", "data"),
|
|
1007
1147
|
State("theme-store", "data"),
|
|
1008
|
-
State("current-workspace-path", "data")
|
|
1148
|
+
State("current-workspace-path", "data"),
|
|
1149
|
+
State("session-id", "data")],
|
|
1009
1150
|
prevent_initial_call=True
|
|
1010
1151
|
)
|
|
1011
|
-
def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_workspace_path):
|
|
1152
|
+
def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_workspace_path, session_id):
|
|
1012
1153
|
"""Phase 1: Immediately show user message and start agent."""
|
|
1013
1154
|
if not message or not message.strip():
|
|
1014
1155
|
raise PreventUpdate
|
|
@@ -1037,11 +1178,19 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
|
|
|
1037
1178
|
|
|
1038
1179
|
messages.append(format_loading(colors))
|
|
1039
1180
|
|
|
1040
|
-
# Calculate
|
|
1041
|
-
|
|
1181
|
+
# Calculate workspace path for agent context
|
|
1182
|
+
# In virtual FS mode, use virtual paths (e.g., /workspace/subdir)
|
|
1183
|
+
# In physical FS mode, use actual filesystem paths
|
|
1184
|
+
if USE_VIRTUAL_FS:
|
|
1185
|
+
# Virtual FS mode: use the virtual path directly
|
|
1186
|
+
# The VirtualFilesystem root is /workspace, so paths are like /workspace or /workspace/subdir
|
|
1187
|
+
workspace_full_path = f"/workspace/{current_workspace_path}" if current_workspace_path else "/workspace"
|
|
1188
|
+
else:
|
|
1189
|
+
# Physical FS mode: use actual filesystem path
|
|
1190
|
+
workspace_full_path = str(WORKSPACE_ROOT / current_workspace_path if current_workspace_path else WORKSPACE_ROOT)
|
|
1042
1191
|
|
|
1043
1192
|
# Start agent in background with workspace context
|
|
1044
|
-
call_agent(message, workspace_path=
|
|
1193
|
+
call_agent(message, workspace_path=workspace_full_path, session_id=session_id)
|
|
1045
1194
|
|
|
1046
1195
|
# Enable polling
|
|
1047
1196
|
return messages, history, "", message, False
|
|
@@ -1055,10 +1204,11 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
|
|
|
1055
1204
|
Input("poll-interval", "n_intervals"),
|
|
1056
1205
|
[State("chat-history", "data"),
|
|
1057
1206
|
State("pending-message", "data"),
|
|
1058
|
-
State("theme-store", "data")
|
|
1207
|
+
State("theme-store", "data"),
|
|
1208
|
+
State("session-id", "data")],
|
|
1059
1209
|
prevent_initial_call=True
|
|
1060
1210
|
)
|
|
1061
|
-
def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
1211
|
+
def poll_agent_updates(n_intervals, history, pending_message, theme, session_id):
|
|
1062
1212
|
"""Poll for agent updates and display them in real-time.
|
|
1063
1213
|
|
|
1064
1214
|
Tool calls are stored in history and persist across turns.
|
|
@@ -1066,7 +1216,7 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1066
1216
|
- {"role": "user", "content": "..."} - user message
|
|
1067
1217
|
- {"role": "assistant", "content": "...", "tool_calls": [...]} - assistant message with tool calls
|
|
1068
1218
|
"""
|
|
1069
|
-
state = get_agent_state()
|
|
1219
|
+
state = get_agent_state(session_id)
|
|
1070
1220
|
history = history or []
|
|
1071
1221
|
colors = get_colors(theme or "light")
|
|
1072
1222
|
|
|
@@ -1196,11 +1346,12 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1196
1346
|
@app.callback(
|
|
1197
1347
|
Output("stop-btn", "style"),
|
|
1198
1348
|
Input("poll-interval", "n_intervals"),
|
|
1349
|
+
State("session-id", "data"),
|
|
1199
1350
|
prevent_initial_call=True
|
|
1200
1351
|
)
|
|
1201
|
-
def update_stop_button_visibility(n_intervals):
|
|
1352
|
+
def update_stop_button_visibility(n_intervals, session_id):
|
|
1202
1353
|
"""Show stop button when agent is running, hide otherwise."""
|
|
1203
|
-
state = get_agent_state()
|
|
1354
|
+
state = get_agent_state(session_id)
|
|
1204
1355
|
if state.get("running"):
|
|
1205
1356
|
return {} # Show button (remove display:none)
|
|
1206
1357
|
else:
|
|
@@ -1213,10 +1364,11 @@ def update_stop_button_visibility(n_intervals):
|
|
|
1213
1364
|
Output("poll-interval", "disabled", allow_duplicate=True)],
|
|
1214
1365
|
Input("stop-btn", "n_clicks"),
|
|
1215
1366
|
[State("chat-history", "data"),
|
|
1216
|
-
State("theme-store", "data")
|
|
1367
|
+
State("theme-store", "data"),
|
|
1368
|
+
State("session-id", "data")],
|
|
1217
1369
|
prevent_initial_call=True
|
|
1218
1370
|
)
|
|
1219
|
-
def handle_stop_button(n_clicks, history, theme):
|
|
1371
|
+
def handle_stop_button(n_clicks, history, theme, session_id):
|
|
1220
1372
|
"""Handle stop button click to stop agent execution."""
|
|
1221
1373
|
if not n_clicks:
|
|
1222
1374
|
raise PreventUpdate
|
|
@@ -1225,7 +1377,7 @@ def handle_stop_button(n_clicks, history, theme):
|
|
|
1225
1377
|
history = history or []
|
|
1226
1378
|
|
|
1227
1379
|
# Request the agent to stop
|
|
1228
|
-
request_agent_stop()
|
|
1380
|
+
request_agent_stop(session_id)
|
|
1229
1381
|
|
|
1230
1382
|
# Render current messages with a stopping indicator
|
|
1231
1383
|
def render_history_messages(history):
|
|
@@ -1267,10 +1419,11 @@ def handle_stop_button(n_clicks, history, theme):
|
|
|
1267
1419
|
Input("interrupt-edit-btn", "n_clicks")],
|
|
1268
1420
|
[State("interrupt-input", "value"),
|
|
1269
1421
|
State("chat-history", "data"),
|
|
1270
|
-
State("theme-store", "data")
|
|
1422
|
+
State("theme-store", "data"),
|
|
1423
|
+
State("session-id", "data")],
|
|
1271
1424
|
prevent_initial_call=True
|
|
1272
1425
|
)
|
|
1273
|
-
def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_value, history, theme):
|
|
1426
|
+
def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_value, history, theme, session_id):
|
|
1274
1427
|
"""Handle user response to an interrupt.
|
|
1275
1428
|
|
|
1276
1429
|
Note: Click parameters are required for Dash callback inputs but we use
|
|
@@ -1312,7 +1465,7 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1312
1465
|
raise PreventUpdate
|
|
1313
1466
|
|
|
1314
1467
|
# Resume the agent with the user's decision
|
|
1315
|
-
resume_agent_from_interrupt(decision, action)
|
|
1468
|
+
resume_agent_from_interrupt(decision, action, session_id=session_id)
|
|
1316
1469
|
|
|
1317
1470
|
# Show loading state while agent resumes
|
|
1318
1471
|
messages = []
|
|
@@ -1349,16 +1502,20 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1349
1502
|
State({"type": "folder-children", "path": ALL}, "style"),
|
|
1350
1503
|
State({"type": "folder-icon", "path": ALL}, "style"),
|
|
1351
1504
|
State({"type": "folder-children", "path": ALL}, "children"),
|
|
1352
|
-
State("theme-store", "data")
|
|
1505
|
+
State("theme-store", "data"),
|
|
1506
|
+
State("session-id", "data")],
|
|
1353
1507
|
prevent_initial_call=True
|
|
1354
1508
|
)
|
|
1355
|
-
def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
|
|
1509
|
+
def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme, session_id):
|
|
1356
1510
|
"""Toggle folder expansion and lazy load contents if needed."""
|
|
1357
1511
|
ctx = callback_context
|
|
1358
1512
|
if not ctx.triggered or not any(n_clicks):
|
|
1359
1513
|
raise PreventUpdate
|
|
1360
1514
|
|
|
1361
1515
|
colors = get_colors(theme or "light")
|
|
1516
|
+
|
|
1517
|
+
# Get workspace for this session (virtual or physical)
|
|
1518
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1362
1519
|
triggered = ctx.triggered[0]["prop_id"]
|
|
1363
1520
|
try:
|
|
1364
1521
|
id_str = triggered.rsplit(".", 1)[0]
|
|
@@ -1400,7 +1557,7 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
|
|
|
1400
1557
|
current_content[0].get("props", {}).get("children") == "Loading..."):
|
|
1401
1558
|
# Load folder contents using real path
|
|
1402
1559
|
try:
|
|
1403
|
-
folder_items = load_folder_contents(folder_rel_path,
|
|
1560
|
+
folder_items = load_folder_contents(folder_rel_path, workspace_root)
|
|
1404
1561
|
loaded_content = render_file_tree(folder_items, colors, STYLES,
|
|
1405
1562
|
level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
|
|
1406
1563
|
parent_path=folder_rel_path)
|
|
@@ -1455,10 +1612,11 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
|
|
|
1455
1612
|
State({"type": "folder-select", "path": ALL}, "data-folderpath"),
|
|
1456
1613
|
State({"type": "folder-select", "path": ALL}, "n_clicks"),
|
|
1457
1614
|
State("current-workspace-path", "data"),
|
|
1458
|
-
State("theme-store", "data")
|
|
1615
|
+
State("theme-store", "data"),
|
|
1616
|
+
State("session-id", "data")],
|
|
1459
1617
|
prevent_initial_call=True
|
|
1460
1618
|
)
|
|
1461
|
-
def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, folder_paths,
|
|
1619
|
+
def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, folder_paths, _prev_clicks, current_path, theme, session_id):
|
|
1462
1620
|
"""Enter a folder (double-click) or navigate via breadcrumb."""
|
|
1463
1621
|
ctx = callback_context
|
|
1464
1622
|
if not ctx.triggered:
|
|
@@ -1499,7 +1657,6 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
|
|
|
1499
1657
|
for i, folder_id in enumerate(folder_ids):
|
|
1500
1658
|
if folder_id["path"] == clicked_path:
|
|
1501
1659
|
current_clicks = folder_clicks[i] if i < len(folder_clicks) else 0
|
|
1502
|
-
previous_clicks = prev_clicks[i] if i < len(prev_clicks) else 0
|
|
1503
1660
|
|
|
1504
1661
|
# Only enter on double-click (clicks increased and is even number >= 2)
|
|
1505
1662
|
if current_clicks and current_clicks >= 2 and current_clicks % 2 == 0:
|
|
@@ -1554,8 +1711,14 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
|
|
|
1554
1711
|
)
|
|
1555
1712
|
)
|
|
1556
1713
|
|
|
1714
|
+
# Get workspace for this session (virtual or physical)
|
|
1715
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1716
|
+
|
|
1557
1717
|
# Calculate the actual workspace path
|
|
1558
|
-
|
|
1718
|
+
if USE_VIRTUAL_FS:
|
|
1719
|
+
workspace_full_path = workspace_root.path(new_path) if new_path else workspace_root.root
|
|
1720
|
+
else:
|
|
1721
|
+
workspace_full_path = workspace_root / new_path if new_path else workspace_root
|
|
1559
1722
|
|
|
1560
1723
|
# Render new file tree
|
|
1561
1724
|
file_tree = render_file_tree(
|
|
@@ -1576,10 +1739,11 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
|
|
|
1576
1739
|
Input({"type": "file-item", "path": ALL}, "n_clicks"),
|
|
1577
1740
|
[State({"type": "file-item", "path": ALL}, "id"),
|
|
1578
1741
|
State("file-click-tracker", "data"),
|
|
1579
|
-
State("theme-store", "data")
|
|
1742
|
+
State("theme-store", "data"),
|
|
1743
|
+
State("session-id", "data")],
|
|
1580
1744
|
prevent_initial_call=True
|
|
1581
1745
|
)
|
|
1582
|
-
def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
|
|
1746
|
+
def open_file_modal(all_n_clicks, all_ids, click_tracker, theme, session_id):
|
|
1583
1747
|
"""Open file in modal - only on actual new clicks."""
|
|
1584
1748
|
ctx = callback_context
|
|
1585
1749
|
|
|
@@ -1627,13 +1791,19 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
|
|
|
1627
1791
|
# Still need to return updated tracker to avoid stale state
|
|
1628
1792
|
raise PreventUpdate
|
|
1629
1793
|
|
|
1794
|
+
# Get workspace for this session (virtual or physical)
|
|
1795
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1796
|
+
|
|
1630
1797
|
# Verify file exists and is a file
|
|
1631
|
-
|
|
1798
|
+
if USE_VIRTUAL_FS:
|
|
1799
|
+
full_path = workspace_root.path(file_path)
|
|
1800
|
+
else:
|
|
1801
|
+
full_path = workspace_root / file_path
|
|
1632
1802
|
if not full_path.exists() or not full_path.is_file():
|
|
1633
1803
|
raise PreventUpdate
|
|
1634
1804
|
|
|
1635
1805
|
colors = get_colors(theme or "light")
|
|
1636
|
-
content, is_text, error = read_file_content(
|
|
1806
|
+
content, is_text, error = read_file_content(workspace_root, file_path)
|
|
1637
1807
|
filename = Path(file_path).name
|
|
1638
1808
|
|
|
1639
1809
|
if is_text and content:
|
|
@@ -1672,10 +1842,11 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
|
|
|
1672
1842
|
@app.callback(
|
|
1673
1843
|
Output("file-download", "data", allow_duplicate=True),
|
|
1674
1844
|
Input("modal-download-btn", "n_clicks"),
|
|
1675
|
-
State("file-to-view", "data"),
|
|
1845
|
+
[State("file-to-view", "data"),
|
|
1846
|
+
State("session-id", "data")],
|
|
1676
1847
|
prevent_initial_call=True
|
|
1677
1848
|
)
|
|
1678
|
-
def download_from_modal(n_clicks, file_path):
|
|
1849
|
+
def download_from_modal(n_clicks, file_path, session_id):
|
|
1679
1850
|
"""Download file from modal."""
|
|
1680
1851
|
ctx = callback_context
|
|
1681
1852
|
if not ctx.triggered:
|
|
@@ -1689,7 +1860,10 @@ def download_from_modal(n_clicks, file_path):
|
|
|
1689
1860
|
if not n_clicks or not file_path:
|
|
1690
1861
|
raise PreventUpdate
|
|
1691
1862
|
|
|
1692
|
-
|
|
1863
|
+
# Get workspace for this session (virtual or physical)
|
|
1864
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1865
|
+
|
|
1866
|
+
b64, filename, mime = get_file_download_data(workspace_root, file_path)
|
|
1693
1867
|
if not b64:
|
|
1694
1868
|
raise PreventUpdate
|
|
1695
1869
|
|
|
@@ -1706,10 +1880,15 @@ def open_terminal(n_clicks):
|
|
|
1706
1880
|
"""Open system terminal at workspace directory."""
|
|
1707
1881
|
if not n_clicks:
|
|
1708
1882
|
raise PreventUpdate
|
|
1709
|
-
|
|
1883
|
+
|
|
1884
|
+
# Terminal doesn't work with virtual filesystem (no physical path)
|
|
1885
|
+
if USE_VIRTUAL_FS:
|
|
1886
|
+
print("Terminal not available in virtual filesystem mode")
|
|
1887
|
+
raise PreventUpdate
|
|
1888
|
+
|
|
1710
1889
|
workspace_path = str(WORKSPACE_ROOT)
|
|
1711
1890
|
system = platform.system()
|
|
1712
|
-
|
|
1891
|
+
|
|
1713
1892
|
try:
|
|
1714
1893
|
if system == "Darwin": # macOS
|
|
1715
1894
|
subprocess.Popen(["open", "-a", "Terminal", workspace_path])
|
|
@@ -1731,7 +1910,7 @@ def open_terminal(n_clicks):
|
|
|
1731
1910
|
continue
|
|
1732
1911
|
except Exception as e:
|
|
1733
1912
|
print(f"Failed to open terminal: {e}")
|
|
1734
|
-
|
|
1913
|
+
|
|
1735
1914
|
raise PreventUpdate
|
|
1736
1915
|
|
|
1737
1916
|
|
|
@@ -1742,27 +1921,31 @@ def open_terminal(n_clicks):
|
|
|
1742
1921
|
Input("refresh-btn", "n_clicks"),
|
|
1743
1922
|
[State("current-workspace-path", "data"),
|
|
1744
1923
|
State("theme-store", "data"),
|
|
1745
|
-
State("collapsed-canvas-items", "data")
|
|
1924
|
+
State("collapsed-canvas-items", "data"),
|
|
1925
|
+
State("session-id", "data")],
|
|
1746
1926
|
prevent_initial_call=True
|
|
1747
1927
|
)
|
|
1748
|
-
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
|
|
1928
|
+
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id):
|
|
1749
1929
|
"""Refresh both file tree and canvas content."""
|
|
1750
|
-
global _agent_state
|
|
1751
1930
|
colors = get_colors(theme or "light")
|
|
1752
1931
|
collapsed_ids = collapsed_ids or []
|
|
1753
1932
|
|
|
1933
|
+
# Get workspace for this session (virtual or physical)
|
|
1934
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1935
|
+
|
|
1754
1936
|
# Calculate current workspace directory
|
|
1755
|
-
|
|
1937
|
+
if USE_VIRTUAL_FS:
|
|
1938
|
+
current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
|
|
1939
|
+
else:
|
|
1940
|
+
current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
|
|
1756
1941
|
|
|
1757
1942
|
# Refresh file tree for current workspace
|
|
1758
1943
|
file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
1759
1944
|
|
|
1760
|
-
#
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
with _agent_state_lock:
|
|
1765
|
-
_agent_state["canvas"] = canvas_items
|
|
1945
|
+
# Re-render canvas from current in-memory state (don't reload from file)
|
|
1946
|
+
# This preserves canvas items that may not have been exported to .canvas/canvas.md yet
|
|
1947
|
+
state = get_agent_state(session_id)
|
|
1948
|
+
canvas_items = state.get("canvas", [])
|
|
1766
1949
|
|
|
1767
1950
|
# Render the canvas items with preserved collapsed state
|
|
1768
1951
|
canvas_content = render_canvas_items(canvas_items, colors, collapsed_ids)
|
|
@@ -1776,17 +1959,25 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
|
|
|
1776
1959
|
Input("file-upload-sidebar", "contents"),
|
|
1777
1960
|
[State("file-upload-sidebar", "filename"),
|
|
1778
1961
|
State("current-workspace-path", "data"),
|
|
1779
|
-
State("theme-store", "data")
|
|
1962
|
+
State("theme-store", "data"),
|
|
1963
|
+
State("session-id", "data")],
|
|
1780
1964
|
prevent_initial_call=True
|
|
1781
1965
|
)
|
|
1782
|
-
def handle_sidebar_upload(contents, filenames, current_workspace, theme):
|
|
1966
|
+
def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id):
|
|
1783
1967
|
"""Handle file uploads from sidebar button to current workspace."""
|
|
1784
1968
|
if not contents:
|
|
1785
1969
|
raise PreventUpdate
|
|
1786
1970
|
|
|
1787
1971
|
colors = get_colors(theme or "light")
|
|
1972
|
+
|
|
1973
|
+
# Get workspace for this session (virtual or physical)
|
|
1974
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1975
|
+
|
|
1788
1976
|
# Calculate current workspace directory
|
|
1789
|
-
|
|
1977
|
+
if USE_VIRTUAL_FS:
|
|
1978
|
+
current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
|
|
1979
|
+
else:
|
|
1980
|
+
current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
|
|
1790
1981
|
|
|
1791
1982
|
for content, filename in zip(contents, filenames):
|
|
1792
1983
|
try:
|
|
@@ -1842,10 +2033,11 @@ def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_op
|
|
|
1842
2033
|
Input("confirm-folder-btn", "n_clicks"),
|
|
1843
2034
|
[State("new-folder-name", "value"),
|
|
1844
2035
|
State("current-workspace-path", "data"),
|
|
1845
|
-
State("theme-store", "data")
|
|
2036
|
+
State("theme-store", "data"),
|
|
2037
|
+
State("session-id", "data")],
|
|
1846
2038
|
prevent_initial_call=True
|
|
1847
2039
|
)
|
|
1848
|
-
def create_folder(n_clicks, folder_name, current_workspace, theme):
|
|
2040
|
+
def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
|
|
1849
2041
|
"""Create a new folder in the current workspace directory."""
|
|
1850
2042
|
if not n_clicks:
|
|
1851
2043
|
raise PreventUpdate
|
|
@@ -1862,8 +2054,15 @@ def create_folder(n_clicks, folder_name, current_workspace, theme):
|
|
|
1862
2054
|
if any(char in folder_name for char in invalid_chars):
|
|
1863
2055
|
return no_update, f"Folder name cannot contain: {' '.join(invalid_chars)}", no_update
|
|
1864
2056
|
|
|
2057
|
+
# Get workspace for this session (virtual or physical)
|
|
2058
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
2059
|
+
|
|
1865
2060
|
# Calculate current workspace directory
|
|
1866
|
-
|
|
2061
|
+
if USE_VIRTUAL_FS:
|
|
2062
|
+
current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
|
|
2063
|
+
else:
|
|
2064
|
+
current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
|
|
2065
|
+
|
|
1867
2066
|
folder_path = current_workspace_dir / folder_name
|
|
1868
2067
|
|
|
1869
2068
|
if folder_path.exists():
|
|
@@ -1935,12 +2134,13 @@ def toggle_view(view_value):
|
|
|
1935
2134
|
[Input("poll-interval", "n_intervals"),
|
|
1936
2135
|
Input("sidebar-view-toggle", "value")],
|
|
1937
2136
|
[State("theme-store", "data"),
|
|
1938
|
-
State("collapsed-canvas-items", "data")
|
|
2137
|
+
State("collapsed-canvas-items", "data"),
|
|
2138
|
+
State("session-id", "data")],
|
|
1939
2139
|
prevent_initial_call=False
|
|
1940
2140
|
)
|
|
1941
|
-
def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
|
|
2141
|
+
def update_canvas_content(n_intervals, view_value, theme, collapsed_ids, session_id):
|
|
1942
2142
|
"""Update canvas content from agent state."""
|
|
1943
|
-
state = get_agent_state()
|
|
2143
|
+
state = get_agent_state(session_id)
|
|
1944
2144
|
canvas_items = state.get("canvas", [])
|
|
1945
2145
|
colors = get_colors(theme or "light")
|
|
1946
2146
|
collapsed_ids = collapsed_ids or []
|
|
@@ -1949,6 +2149,50 @@ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
|
|
|
1949
2149
|
return render_canvas_items(canvas_items, colors, collapsed_ids)
|
|
1950
2150
|
|
|
1951
2151
|
|
|
2152
|
+
# File tree polling update - refresh file tree during agent execution
|
|
2153
|
+
@app.callback(
|
|
2154
|
+
Output("file-tree", "children", allow_duplicate=True),
|
|
2155
|
+
Input("poll-interval", "n_intervals"),
|
|
2156
|
+
[State("current-workspace-path", "data"),
|
|
2157
|
+
State("theme-store", "data"),
|
|
2158
|
+
State("session-id", "data"),
|
|
2159
|
+
State("sidebar-view-toggle", "value")],
|
|
2160
|
+
prevent_initial_call=True
|
|
2161
|
+
)
|
|
2162
|
+
def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, view_value):
|
|
2163
|
+
"""Refresh file tree during agent execution to show newly created files.
|
|
2164
|
+
|
|
2165
|
+
This callback runs on each poll interval and refreshes the file tree
|
|
2166
|
+
so that files created by the agent are visible in real-time.
|
|
2167
|
+
Only updates when viewing files (not canvas).
|
|
2168
|
+
"""
|
|
2169
|
+
# Only refresh when viewing files panel
|
|
2170
|
+
if view_value != "files":
|
|
2171
|
+
raise PreventUpdate
|
|
2172
|
+
|
|
2173
|
+
# Get agent state to check if we should refresh
|
|
2174
|
+
state = get_agent_state(session_id)
|
|
2175
|
+
|
|
2176
|
+
# Only refresh if agent is running or just finished (within last update window)
|
|
2177
|
+
# This avoids unnecessary refreshes when agent is idle
|
|
2178
|
+
last_update = state.get("last_update", 0)
|
|
2179
|
+
if not state["running"] and (time.time() - last_update) > 2:
|
|
2180
|
+
raise PreventUpdate
|
|
2181
|
+
|
|
2182
|
+
colors = get_colors(theme or "light")
|
|
2183
|
+
|
|
2184
|
+
# Get workspace for this session (virtual or physical)
|
|
2185
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
2186
|
+
|
|
2187
|
+
# Calculate current workspace directory
|
|
2188
|
+
if USE_VIRTUAL_FS:
|
|
2189
|
+
current_workspace_dir = workspace_root.path(current_workspace) if current_workspace else workspace_root.root
|
|
2190
|
+
else:
|
|
2191
|
+
current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
|
|
2192
|
+
|
|
2193
|
+
# Refresh file tree
|
|
2194
|
+
return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
2195
|
+
|
|
1952
2196
|
|
|
1953
2197
|
# Open clear canvas confirmation modal
|
|
1954
2198
|
@app.callback(
|
|
@@ -1970,10 +2214,11 @@ def open_clear_canvas_modal(n_clicks):
|
|
|
1970
2214
|
Output("collapsed-canvas-items", "data", allow_duplicate=True)],
|
|
1971
2215
|
[Input("confirm-clear-canvas-btn", "n_clicks"),
|
|
1972
2216
|
Input("cancel-clear-canvas-btn", "n_clicks")],
|
|
1973
|
-
[State("theme-store", "data")
|
|
2217
|
+
[State("theme-store", "data"),
|
|
2218
|
+
State("session-id", "data")],
|
|
1974
2219
|
prevent_initial_call=True
|
|
1975
2220
|
)
|
|
1976
|
-
def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
|
|
2221
|
+
def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme, session_id):
|
|
1977
2222
|
"""Handle the clear canvas confirmation - either clear or cancel."""
|
|
1978
2223
|
ctx = callback_context
|
|
1979
2224
|
if not ctx.triggered:
|
|
@@ -1994,15 +2239,31 @@ def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
|
|
|
1994
2239
|
|
|
1995
2240
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1996
2241
|
|
|
2242
|
+
# Get workspace for this session (virtual or physical)
|
|
2243
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
2244
|
+
|
|
1997
2245
|
# Archive .canvas folder if it exists (contains canvas.md and all assets)
|
|
1998
|
-
|
|
1999
|
-
if
|
|
2246
|
+
# Note: Archive only works with physical filesystem
|
|
2247
|
+
if not USE_VIRTUAL_FS:
|
|
2248
|
+
canvas_dir = workspace_root / ".canvas"
|
|
2249
|
+
if canvas_dir.exists() and canvas_dir.is_dir():
|
|
2250
|
+
try:
|
|
2251
|
+
archive_dir = workspace_root / f".canvas_{timestamp}"
|
|
2252
|
+
shutil.move(str(canvas_dir), str(archive_dir))
|
|
2253
|
+
print(f"Archived .canvas folder to {archive_dir}")
|
|
2254
|
+
except Exception as e:
|
|
2255
|
+
print(f"Failed to archive .canvas folder: {e}")
|
|
2256
|
+
else:
|
|
2257
|
+
# For virtual FS, just clear the .canvas directory
|
|
2000
2258
|
try:
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2259
|
+
canvas_path = workspace_root.path("/.canvas")
|
|
2260
|
+
if canvas_path.exists():
|
|
2261
|
+
# Clear files in the .canvas directory
|
|
2262
|
+
for item in canvas_path.iterdir():
|
|
2263
|
+
if item.is_file():
|
|
2264
|
+
item.unlink()
|
|
2004
2265
|
except Exception as e:
|
|
2005
|
-
print(f"Failed to
|
|
2266
|
+
print(f"Failed to clear virtual canvas: {e}")
|
|
2006
2267
|
|
|
2007
2268
|
# Clear canvas in state
|
|
2008
2269
|
with _agent_state_lock:
|
|
@@ -2158,10 +2419,11 @@ def open_delete_confirmation(all_clicks, all_ids):
|
|
|
2158
2419
|
Input("cancel-delete-canvas-btn", "n_clicks")],
|
|
2159
2420
|
[State("delete-canvas-item-id", "data"),
|
|
2160
2421
|
State("theme-store", "data"),
|
|
2161
|
-
State("collapsed-canvas-items", "data")
|
|
2422
|
+
State("collapsed-canvas-items", "data"),
|
|
2423
|
+
State("session-id", "data")],
|
|
2162
2424
|
prevent_initial_call=True
|
|
2163
2425
|
)
|
|
2164
|
-
def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, collapsed_ids):
|
|
2426
|
+
def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, collapsed_ids, session_id):
|
|
2165
2427
|
"""Handle the delete confirmation - either delete or cancel."""
|
|
2166
2428
|
ctx = callback_context
|
|
2167
2429
|
if not ctx.triggered:
|
|
@@ -2177,23 +2439,34 @@ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, co
|
|
|
2177
2439
|
if not confirm_clicks or not item_id:
|
|
2178
2440
|
raise PreventUpdate
|
|
2179
2441
|
|
|
2180
|
-
global _agent_state
|
|
2181
2442
|
colors = get_colors(theme or "light")
|
|
2182
2443
|
collapsed_ids = collapsed_ids or []
|
|
2183
2444
|
|
|
2184
|
-
#
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2445
|
+
# Get workspace for this session (virtual or physical)
|
|
2446
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
2447
|
+
|
|
2448
|
+
# Remove the item from canvas (session-specific in virtual FS mode)
|
|
2449
|
+
if USE_VIRTUAL_FS and session_id:
|
|
2450
|
+
current_state = _get_session_state(session_id)
|
|
2451
|
+
with _session_agents_lock:
|
|
2452
|
+
current_state["canvas"] = [
|
|
2453
|
+
item for item in current_state.get("canvas", [])
|
|
2454
|
+
if item.get("id") != item_id
|
|
2455
|
+
]
|
|
2456
|
+
canvas_items = current_state["canvas"].copy()
|
|
2457
|
+
else:
|
|
2458
|
+
with _agent_state_lock:
|
|
2459
|
+
_agent_state["canvas"] = [
|
|
2460
|
+
item for item in _agent_state["canvas"]
|
|
2461
|
+
if item.get("id") != item_id
|
|
2462
|
+
]
|
|
2463
|
+
canvas_items = _agent_state["canvas"].copy()
|
|
2191
2464
|
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2465
|
+
# Export updated canvas to markdown file
|
|
2466
|
+
try:
|
|
2467
|
+
export_canvas_to_markdown(canvas_items, workspace_root)
|
|
2468
|
+
except Exception as e:
|
|
2469
|
+
print(f"Failed to export canvas after delete: {e}")
|
|
2197
2470
|
|
|
2198
2471
|
# Remove deleted item from collapsed_ids if present
|
|
2199
2472
|
new_collapsed_ids = [cid for cid in collapsed_ids if cid != item_id]
|
|
@@ -2267,7 +2540,8 @@ def run_app(
|
|
|
2267
2540
|
title=None,
|
|
2268
2541
|
subtitle=None,
|
|
2269
2542
|
welcome_message=None,
|
|
2270
|
-
config_file=None
|
|
2543
|
+
config_file=None,
|
|
2544
|
+
virtual_fs=None
|
|
2271
2545
|
):
|
|
2272
2546
|
"""
|
|
2273
2547
|
Run DeepAgent Dash programmatically.
|
|
@@ -2289,6 +2563,9 @@ def run_app(
|
|
|
2289
2563
|
subtitle (str, optional): Application subtitle
|
|
2290
2564
|
welcome_message (str, optional): Welcome message shown on startup (supports markdown)
|
|
2291
2565
|
config_file (str, optional): Path to config file (default: ./config.py)
|
|
2566
|
+
virtual_fs (bool, optional): Use in-memory virtual filesystem instead of disk.
|
|
2567
|
+
When enabled, each session gets isolated ephemeral storage.
|
|
2568
|
+
Can also be set via DEEPAGENT_SESSION_ISOLATION=true environment variable.
|
|
2292
2569
|
|
|
2293
2570
|
Returns:
|
|
2294
2571
|
int: Exit code (0 for success, non-zero for error)
|
|
@@ -2308,7 +2585,10 @@ def run_app(
|
|
|
2308
2585
|
>>> # Without agent (manual mode)
|
|
2309
2586
|
>>> run_app(workspace="~/my-workspace", debug=True)
|
|
2310
2587
|
"""
|
|
2311
|
-
global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, WELCOME_MESSAGE, agent, AGENT_ERROR, args
|
|
2588
|
+
global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, WELCOME_MESSAGE, agent, AGENT_ERROR, args, USE_VIRTUAL_FS
|
|
2589
|
+
|
|
2590
|
+
# Determine virtual filesystem mode (CLI arg > env var > config default)
|
|
2591
|
+
USE_VIRTUAL_FS = virtual_fs if virtual_fs is not None else config.VIRTUAL_FS
|
|
2312
2592
|
|
|
2313
2593
|
# Load config file if specified and exists
|
|
2314
2594
|
config_module = None
|
|
@@ -2378,16 +2658,22 @@ def run_app(
|
|
|
2378
2658
|
# Use default config agent
|
|
2379
2659
|
agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
|
|
2380
2660
|
|
|
2381
|
-
#
|
|
2382
|
-
|
|
2661
|
+
# Update global agent state
|
|
2662
|
+
global _agent_state
|
|
2383
2663
|
|
|
2384
|
-
#
|
|
2385
|
-
|
|
2386
|
-
|
|
2664
|
+
# Ensure workspace exists (only for physical filesystem mode)
|
|
2665
|
+
if not USE_VIRTUAL_FS:
|
|
2666
|
+
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
2387
2667
|
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2668
|
+
# Set environment variable for agent to access workspace
|
|
2669
|
+
# This allows user agents to read DEEPAGENT_WORKSPACE_ROOT
|
|
2670
|
+
os.environ['DEEPAGENT_WORKSPACE_ROOT'] = str(WORKSPACE_ROOT)
|
|
2671
|
+
|
|
2672
|
+
# Update global state to use the configured workspace
|
|
2673
|
+
_agent_state["canvas"] = load_canvas_from_markdown(WORKSPACE_ROOT)
|
|
2674
|
+
else:
|
|
2675
|
+
# For virtual FS, canvas is loaded per-session in callbacks
|
|
2676
|
+
_agent_state["canvas"] = []
|
|
2391
2677
|
|
|
2392
2678
|
# Create a mock args object for compatibility with existing code
|
|
2393
2679
|
class Args:
|
|
@@ -2400,9 +2686,13 @@ def run_app(
|
|
|
2400
2686
|
print("\n" + "="*50)
|
|
2401
2687
|
print(f" {APP_TITLE}")
|
|
2402
2688
|
print("="*50)
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
print(
|
|
2689
|
+
if USE_VIRTUAL_FS:
|
|
2690
|
+
print(" Filesystem: Virtual (in-memory, ephemeral)")
|
|
2691
|
+
print(" Sessions are isolated and data is not persisted")
|
|
2692
|
+
else:
|
|
2693
|
+
print(f" Workspace: {WORKSPACE_ROOT}")
|
|
2694
|
+
if workspace:
|
|
2695
|
+
print(f" (from CLI: --workspace {workspace})")
|
|
2406
2696
|
print(f" Agent: {'Ready' if agent else 'Not available'}")
|
|
2407
2697
|
if agent_spec:
|
|
2408
2698
|
print(f" (from CLI: --agent {agent_spec})")
|
|
@@ -2418,25 +2708,4 @@ def run_app(
|
|
|
2418
2708
|
return 0
|
|
2419
2709
|
except Exception as e:
|
|
2420
2710
|
print(f"\n❌ Error running app: {e}")
|
|
2421
|
-
return 1
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
# =============================================================================
|
|
2425
|
-
# MAIN - BACKWARDS COMPATIBILITY
|
|
2426
|
-
# =============================================================================
|
|
2427
|
-
|
|
2428
|
-
if __name__ == "__main__":
|
|
2429
|
-
# Parse CLI arguments
|
|
2430
|
-
args = parse_args()
|
|
2431
|
-
|
|
2432
|
-
# When run directly (not as package), use original CLI arg parsing
|
|
2433
|
-
sys.exit(run_app(
|
|
2434
|
-
workspace=args.workspace if args.workspace else None,
|
|
2435
|
-
agent_spec=args.agent if args.agent else None,
|
|
2436
|
-
port=args.port if args.port else None,
|
|
2437
|
-
host=args.host if args.host else None,
|
|
2438
|
-
debug=args.debug if args.debug else (not args.no_debug if args.no_debug else None),
|
|
2439
|
-
title=args.title if args.title else None,
|
|
2440
|
-
subtitle=args.subtitle if args.subtitle else None,
|
|
2441
|
-
config_file=args.config if args.config else None
|
|
2442
|
-
))
|
|
2711
|
+
return 1
|