cowork-dash 0.1.5__py3-none-any.whl → 0.1.7__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 +69 -25
- cowork_dash/app.py +636 -321
- 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 +11 -2
- cowork_dash/tools.py +196 -7
- cowork_dash/virtual_fs.py +468 -0
- {cowork_dash-0.1.5.dist-info → cowork_dash-0.1.7.dist-info}/METADATA +1 -1
- cowork_dash-0.1.7.dist-info/RECORD +22 -0
- cowork_dash-0.1.5.dist-info/RECORD +0 -20
- {cowork_dash-0.1.5.dist-info → cowork_dash-0.1.7.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.5.dist-info → cowork_dash-0.1.7.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.5.dist-info → cowork_dash-0.1.7.dist-info}/licenses/LICENSE +0 -0
cowork_dash/app.py
CHANGED
|
@@ -4,12 +4,12 @@ import sys
|
|
|
4
4
|
import json
|
|
5
5
|
import base64
|
|
6
6
|
import re
|
|
7
|
+
import copy
|
|
7
8
|
import shutil
|
|
8
9
|
import platform
|
|
9
10
|
import subprocess
|
|
10
11
|
import threading
|
|
11
12
|
import time
|
|
12
|
-
import argparse
|
|
13
13
|
import importlib.util
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from datetime import datetime
|
|
@@ -17,20 +17,20 @@ from typing import Optional, Dict, Any, List
|
|
|
17
17
|
from dotenv import load_dotenv
|
|
18
18
|
load_dotenv()
|
|
19
19
|
|
|
20
|
-
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
|
|
21
21
|
from dash.exceptions import PreventUpdate
|
|
22
22
|
import dash_mantine_components as dmc
|
|
23
23
|
from dash_iconify import DashIconify
|
|
24
24
|
|
|
25
25
|
# Import custom modules
|
|
26
|
-
from .canvas import
|
|
26
|
+
from .canvas import export_canvas_to_markdown, load_canvas_from_markdown
|
|
27
27
|
from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
|
|
28
28
|
from .components import (
|
|
29
|
-
format_message, format_loading, format_thinking,
|
|
30
|
-
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,
|
|
31
30
|
format_interrupt
|
|
32
31
|
)
|
|
33
32
|
from .layout import create_layout as create_layout_component
|
|
33
|
+
from .virtual_fs import get_session_manager
|
|
34
34
|
|
|
35
35
|
# Import configuration defaults
|
|
36
36
|
from . import config
|
|
@@ -38,88 +38,6 @@ from . import config
|
|
|
38
38
|
# Generate thread ID
|
|
39
39
|
thread_id = str(uuid.uuid4())
|
|
40
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
41
|
def load_agent_from_spec(agent_spec: str):
|
|
124
42
|
"""
|
|
125
43
|
Load agent from specification string.
|
|
@@ -202,14 +120,56 @@ PORT = config.PORT
|
|
|
202
120
|
HOST = config.HOST
|
|
203
121
|
DEBUG = config.DEBUG
|
|
204
122
|
WELCOME_MESSAGE = config.WELCOME_MESSAGE
|
|
123
|
+
USE_VIRTUAL_FS = config.VIRTUAL_FS # Can be overridden by --virtual-fs CLI arg
|
|
205
124
|
|
|
206
|
-
# Ensure workspace exists
|
|
207
|
-
|
|
125
|
+
# Ensure workspace exists (only for physical filesystem mode)
|
|
126
|
+
if not USE_VIRTUAL_FS:
|
|
127
|
+
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
208
128
|
|
|
209
129
|
# Initialize agent from config
|
|
210
130
|
agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
|
|
211
131
|
|
|
212
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
|
+
|
|
213
173
|
# =============================================================================
|
|
214
174
|
# STYLING
|
|
215
175
|
# =============================================================================
|
|
@@ -277,13 +237,13 @@ def get_colors(theme: str = "light") -> dict:
|
|
|
277
237
|
# AGENT INTERACTION - WITH REAL-TIME STREAMING
|
|
278
238
|
# =============================================================================
|
|
279
239
|
|
|
280
|
-
# Global state for streaming updates
|
|
240
|
+
# Global state for streaming updates (used in physical FS mode)
|
|
281
241
|
_agent_state = {
|
|
282
242
|
"running": False,
|
|
283
243
|
"thinking": "",
|
|
284
244
|
"todos": [],
|
|
285
245
|
"tool_calls": [], # Current turn's tool calls (reset each turn)
|
|
286
|
-
"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)
|
|
287
247
|
"response": "",
|
|
288
248
|
"error": None,
|
|
289
249
|
"interrupt": None, # Track interrupt requests for human-in-the-loop
|
|
@@ -293,25 +253,110 @@ _agent_state = {
|
|
|
293
253
|
}
|
|
294
254
|
_agent_state_lock = threading.Lock()
|
|
295
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
|
+
}
|
|
296
278
|
|
|
297
|
-
def request_agent_stop():
|
|
298
|
-
"""Request the agent to stop execution."""
|
|
299
|
-
with _agent_state_lock:
|
|
300
|
-
_agent_state["stop_requested"] = True
|
|
301
|
-
_agent_state["last_update"] = time.time()
|
|
302
279
|
|
|
303
|
-
def
|
|
304
|
-
"""
|
|
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()
|
|
332
|
+
|
|
333
|
+
|
|
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.
|
|
305
336
|
|
|
306
337
|
Args:
|
|
307
338
|
message: User message to send to agent
|
|
308
339
|
resume_data: Optional dict with 'decisions' to resume from interrupt
|
|
309
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)
|
|
310
342
|
"""
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
315
360
|
return
|
|
316
361
|
|
|
317
362
|
# Track tool calls by their ID for updating status
|
|
@@ -338,17 +383,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
338
383
|
|
|
339
384
|
def _update_tool_call_result(tool_call_id: str, result: Any, status: str = "success"):
|
|
340
385
|
"""Update a tool call with its result."""
|
|
341
|
-
with
|
|
342
|
-
for tc in
|
|
386
|
+
with state_lock:
|
|
387
|
+
for tc in current_state["tool_calls"]:
|
|
343
388
|
if tc.get("id") == tool_call_id:
|
|
344
389
|
tc["result"] = result
|
|
345
390
|
tc["status"] = status
|
|
346
391
|
break
|
|
347
|
-
|
|
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)
|
|
348
399
|
|
|
349
400
|
try:
|
|
350
401
|
# Prepare input based on whether we're resuming or starting fresh
|
|
351
|
-
stream_config = dict(configurable=dict(thread_id=
|
|
402
|
+
stream_config = dict(configurable=dict(thread_id=current_thread_id))
|
|
352
403
|
|
|
353
404
|
if message == "__RESUME__":
|
|
354
405
|
# Resume from interrupt
|
|
@@ -363,24 +414,24 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
363
414
|
message_with_context = message
|
|
364
415
|
agent_input = {"messages": [{"role": "user", "content": message_with_context}]}
|
|
365
416
|
|
|
366
|
-
for update in
|
|
417
|
+
for update in current_agent.stream(agent_input, stream_mode="updates", config=stream_config):
|
|
367
418
|
# Check if stop was requested
|
|
368
|
-
with
|
|
369
|
-
if
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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()
|
|
374
425
|
return
|
|
375
426
|
|
|
376
427
|
# Check for interrupt
|
|
377
428
|
if isinstance(update, dict) and "__interrupt__" in update:
|
|
378
429
|
interrupt_value = update["__interrupt__"]
|
|
379
430
|
interrupt_data = _process_interrupt(interrupt_value)
|
|
380
|
-
with
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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()
|
|
384
435
|
return # Exit stream, wait for user to resume
|
|
385
436
|
|
|
386
437
|
if isinstance(update, dict):
|
|
@@ -399,9 +450,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
399
450
|
tool_call_map[serialized["id"]] = serialized
|
|
400
451
|
new_tool_calls.append(serialized)
|
|
401
452
|
|
|
402
|
-
with
|
|
403
|
-
|
|
404
|
-
|
|
453
|
+
with state_lock:
|
|
454
|
+
current_state["tool_calls"].extend(new_tool_calls)
|
|
455
|
+
current_state["last_update"] = time.time()
|
|
405
456
|
|
|
406
457
|
elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
|
|
407
458
|
# Update tool call status when we get the result
|
|
@@ -450,9 +501,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
450
501
|
thinking_text = content.get('reflection', str(content))
|
|
451
502
|
|
|
452
503
|
# Update state immediately
|
|
453
|
-
with
|
|
454
|
-
|
|
455
|
-
|
|
504
|
+
with state_lock:
|
|
505
|
+
current_state["thinking"] = thinking_text
|
|
506
|
+
current_state["last_update"] = time.time()
|
|
456
507
|
|
|
457
508
|
elif last_msg.name == 'write_todos':
|
|
458
509
|
content = last_msg.content
|
|
@@ -472,9 +523,9 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
472
523
|
todos = content
|
|
473
524
|
|
|
474
525
|
# Update state immediately
|
|
475
|
-
with
|
|
476
|
-
|
|
477
|
-
|
|
526
|
+
with state_lock:
|
|
527
|
+
current_state["todos"] = todos
|
|
528
|
+
current_state["last_update"] = time.time()
|
|
478
529
|
|
|
479
530
|
elif last_msg.name == 'add_to_canvas':
|
|
480
531
|
content = last_msg.content
|
|
@@ -492,15 +543,16 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
492
543
|
canvas_item = {"type": "markdown", "data": str(content)}
|
|
493
544
|
|
|
494
545
|
# Update state immediately - append to canvas
|
|
495
|
-
with
|
|
496
|
-
|
|
497
|
-
|
|
546
|
+
with state_lock:
|
|
547
|
+
current_state["canvas"].append(canvas_item)
|
|
548
|
+
current_state["last_update"] = time.time()
|
|
498
549
|
|
|
499
|
-
# Also export to markdown file
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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}")
|
|
504
556
|
|
|
505
557
|
elif last_msg.name == 'update_canvas_item':
|
|
506
558
|
content = last_msg.content
|
|
@@ -517,22 +569,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
517
569
|
|
|
518
570
|
item_id = canvas_item.get("id")
|
|
519
571
|
if item_id:
|
|
520
|
-
with
|
|
572
|
+
with state_lock:
|
|
521
573
|
# Find and replace the item with matching ID
|
|
522
|
-
for i, existing in enumerate(
|
|
574
|
+
for i, existing in enumerate(current_state["canvas"]):
|
|
523
575
|
if existing.get("id") == item_id:
|
|
524
|
-
|
|
576
|
+
current_state["canvas"][i] = canvas_item
|
|
525
577
|
break
|
|
526
578
|
else:
|
|
527
579
|
# If not found, append as new item
|
|
528
|
-
|
|
529
|
-
|
|
580
|
+
current_state["canvas"].append(canvas_item)
|
|
581
|
+
current_state["last_update"] = time.time()
|
|
530
582
|
|
|
531
|
-
# Export to markdown file
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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}")
|
|
536
589
|
|
|
537
590
|
elif last_msg.name == 'remove_canvas_item':
|
|
538
591
|
content = last_msg.content
|
|
@@ -549,18 +602,19 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
549
602
|
item_id = None
|
|
550
603
|
|
|
551
604
|
if item_id:
|
|
552
|
-
with
|
|
553
|
-
|
|
554
|
-
item for item in
|
|
605
|
+
with state_lock:
|
|
606
|
+
current_state["canvas"] = [
|
|
607
|
+
item for item in current_state["canvas"]
|
|
555
608
|
if item.get("id") != item_id
|
|
556
609
|
]
|
|
557
|
-
|
|
610
|
+
current_state["last_update"] = time.time()
|
|
558
611
|
|
|
559
|
-
# Export to markdown file
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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}")
|
|
564
618
|
|
|
565
619
|
elif last_msg.name in ('execute_cell', 'execute_all_cells'):
|
|
566
620
|
# Extract canvas_items from cell execution results
|
|
@@ -589,17 +643,18 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
589
643
|
|
|
590
644
|
# Add any canvas items found
|
|
591
645
|
if canvas_items_to_add:
|
|
592
|
-
with
|
|
646
|
+
with state_lock:
|
|
593
647
|
for item in canvas_items_to_add:
|
|
594
648
|
if isinstance(item, dict) and item.get('type'):
|
|
595
|
-
|
|
596
|
-
|
|
649
|
+
current_state["canvas"].append(item)
|
|
650
|
+
current_state["last_update"] = time.time()
|
|
597
651
|
|
|
598
|
-
# Export to markdown file
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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}")
|
|
603
658
|
|
|
604
659
|
elif hasattr(last_msg, 'content'):
|
|
605
660
|
content = last_msg.content
|
|
@@ -617,19 +672,23 @@ def _run_agent_stream(message: str, resume_data: Dict = None, workspace_path: st
|
|
|
617
672
|
response_text = " ".join(text_parts).strip()
|
|
618
673
|
|
|
619
674
|
if response_text:
|
|
620
|
-
with
|
|
621
|
-
|
|
622
|
-
|
|
675
|
+
with state_lock:
|
|
676
|
+
current_state["response"] = response_text
|
|
677
|
+
current_state["last_update"] = time.time()
|
|
623
678
|
|
|
624
679
|
except Exception as e:
|
|
625
|
-
with
|
|
626
|
-
|
|
627
|
-
|
|
680
|
+
with state_lock:
|
|
681
|
+
current_state["error"] = str(e)
|
|
682
|
+
current_state["response"] = f"Error: {str(e)}"
|
|
628
683
|
|
|
629
684
|
finally:
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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()
|
|
633
692
|
|
|
634
693
|
|
|
635
694
|
def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
@@ -728,20 +787,29 @@ def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
|
728
787
|
|
|
729
788
|
return interrupt_data
|
|
730
789
|
|
|
731
|
-
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):
|
|
732
791
|
"""Start agent execution in background thread.
|
|
733
792
|
|
|
734
793
|
Args:
|
|
735
794
|
message: User message to send to agent
|
|
736
795
|
resume_data: Optional dict with decisions to resume from interrupt
|
|
737
796
|
workspace_path: Current workspace directory path to inject into agent context
|
|
797
|
+
session_id: Session ID for virtual FS mode
|
|
738
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
|
+
|
|
739
807
|
# Reset state but preserve canvas - do it all atomically
|
|
740
|
-
with
|
|
741
|
-
existing_canvas =
|
|
808
|
+
with state_lock:
|
|
809
|
+
existing_canvas = current_state.get("canvas", []).copy()
|
|
742
810
|
|
|
743
|
-
|
|
744
|
-
|
|
811
|
+
current_state.clear()
|
|
812
|
+
current_state.update({
|
|
745
813
|
"running": True,
|
|
746
814
|
"thinking": "",
|
|
747
815
|
"todos": [],
|
|
@@ -756,21 +824,30 @@ def call_agent(message: str, resume_data: Dict = None, workspace_path: str = Non
|
|
|
756
824
|
})
|
|
757
825
|
|
|
758
826
|
# Start background thread
|
|
759
|
-
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))
|
|
760
828
|
thread.daemon = True
|
|
761
829
|
thread.start()
|
|
762
830
|
|
|
763
831
|
|
|
764
|
-
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):
|
|
765
833
|
"""Resume agent from an interrupt with the user's decision.
|
|
766
834
|
|
|
767
835
|
Args:
|
|
768
836
|
decision: User's response/decision text
|
|
769
837
|
action: One of 'approve', 'reject', 'edit'
|
|
770
838
|
action_requests: List of action requests from the interrupt (for edit mode)
|
|
839
|
+
session_id: Session ID for virtual FS mode
|
|
771
840
|
"""
|
|
772
|
-
|
|
773
|
-
|
|
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")
|
|
774
851
|
if not interrupt_data:
|
|
775
852
|
return
|
|
776
853
|
|
|
@@ -779,16 +856,16 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
|
|
|
779
856
|
action_requests = interrupt_data.get("action_requests", [])
|
|
780
857
|
|
|
781
858
|
# Clear interrupt and set running, but preserve tool_calls and canvas
|
|
782
|
-
existing_tool_calls =
|
|
783
|
-
existing_canvas =
|
|
859
|
+
existing_tool_calls = current_state.get("tool_calls", []).copy()
|
|
860
|
+
existing_canvas = current_state.get("canvas", []).copy()
|
|
784
861
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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()
|
|
792
869
|
|
|
793
870
|
# Build decisions list in the format expected by deepagents HITL middleware
|
|
794
871
|
# Format: {"decisions": [{"type": "approve"}, {"type": "reject", "message": "..."}, ...]}
|
|
@@ -812,10 +889,10 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
|
|
|
812
889
|
tool_names = [ar.get("tool", "unknown") for ar in action_requests]
|
|
813
890
|
tool_info = f" ({', '.join(tool_names)})"
|
|
814
891
|
|
|
815
|
-
with
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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()
|
|
819
896
|
|
|
820
897
|
return # Don't resume the agent
|
|
821
898
|
else: # edit - provide edited action
|
|
@@ -845,14 +922,62 @@ def resume_agent_from_interrupt(decision: str, action: str = "approve", action_r
|
|
|
845
922
|
|
|
846
923
|
# Start background thread with resume value
|
|
847
924
|
# Pass a special marker to indicate this is a resume operation
|
|
848
|
-
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))
|
|
849
926
|
thread.daemon = True
|
|
850
927
|
thread.start()
|
|
851
928
|
|
|
852
|
-
def get_agent_state() -> Dict[str, Any]:
|
|
853
|
-
"""Get current agent state (thread-safe).
|
|
854
|
-
|
|
855
|
-
|
|
929
|
+
def get_agent_state(session_id: Optional[str] = None) -> Dict[str, Any]:
|
|
930
|
+
"""Get current agent state (thread-safe).
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
session_id: Session ID for virtual FS mode, None for physical FS mode.
|
|
934
|
+
|
|
935
|
+
Returns a deep copy of mutable collections to prevent race conditions.
|
|
936
|
+
"""
|
|
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()
|
|
946
|
+
# Deep copy mutable collections to prevent race conditions during rendering
|
|
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"])
|
|
950
|
+
return state
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def reset_agent_state(session_id: Optional[str] = None):
|
|
954
|
+
"""Reset agent state for a fresh session (thread-safe).
|
|
955
|
+
|
|
956
|
+
Called on page load to ensure clean state after browser refresh.
|
|
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.
|
|
961
|
+
"""
|
|
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)
|
|
856
981
|
|
|
857
982
|
# =============================================================================
|
|
858
983
|
# DASH APP
|
|
@@ -899,8 +1024,12 @@ def create_layout():
|
|
|
899
1024
|
title = getattr(agent, 'name', None) or APP_TITLE
|
|
900
1025
|
subtitle = getattr(agent, 'description', None) or APP_SUBTITLE
|
|
901
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
|
+
|
|
902
1031
|
return create_layout_component(
|
|
903
|
-
workspace_root=
|
|
1032
|
+
workspace_root=workspace_for_layout,
|
|
904
1033
|
app_title=title,
|
|
905
1034
|
app_subtitle=subtitle,
|
|
906
1035
|
colors=COLORS,
|
|
@@ -921,15 +1050,37 @@ app.layout = create_layout
|
|
|
921
1050
|
|
|
922
1051
|
# Initial message display
|
|
923
1052
|
@app.callback(
|
|
924
|
-
Output("chat-messages", "children"),
|
|
1053
|
+
[Output("chat-messages", "children"),
|
|
1054
|
+
Output("skip-history-render", "data", allow_duplicate=True),
|
|
1055
|
+
Output("session-initialized", "data", allow_duplicate=True),
|
|
1056
|
+
Output("session-id", "data", allow_duplicate=True)],
|
|
925
1057
|
[Input("chat-history", "data")],
|
|
926
|
-
[State("theme-store", "data")
|
|
927
|
-
|
|
1058
|
+
[State("theme-store", "data"),
|
|
1059
|
+
State("skip-history-render", "data"),
|
|
1060
|
+
State("session-initialized", "data"),
|
|
1061
|
+
State("session-id", "data")],
|
|
1062
|
+
prevent_initial_call='initial_duplicate'
|
|
928
1063
|
)
|
|
929
|
-
def display_initial_messages(history, theme):
|
|
930
|
-
"""Display initial welcome message or chat history.
|
|
1064
|
+
def display_initial_messages(history, theme, skip_render, session_initialized, session_id):
|
|
1065
|
+
"""Display initial welcome message or chat history.
|
|
1066
|
+
|
|
1067
|
+
On first call (page load), resets agent state for a fresh session.
|
|
1068
|
+
Skip rendering if skip_render flag is set - this prevents duplicate renders
|
|
1069
|
+
when poll_agent_updates already handles the rendering.
|
|
1070
|
+
"""
|
|
1071
|
+
# Initialize session on page load (first callback trigger)
|
|
1072
|
+
new_session_id = session_id
|
|
1073
|
+
if not session_initialized:
|
|
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)
|
|
1077
|
+
|
|
1078
|
+
# Skip if flag is set (poll_agent_updates already rendered)
|
|
1079
|
+
if skip_render:
|
|
1080
|
+
return no_update, False, True, new_session_id # Reset skip flag, mark session initialized
|
|
1081
|
+
|
|
931
1082
|
if not history:
|
|
932
|
-
return []
|
|
1083
|
+
return [], False, True, new_session_id
|
|
933
1084
|
|
|
934
1085
|
colors = get_colors(theme or "light")
|
|
935
1086
|
messages = []
|
|
@@ -946,7 +1097,41 @@ def display_initial_messages(history, theme):
|
|
|
946
1097
|
todos_block = format_todos_inline(msg["todos"], colors)
|
|
947
1098
|
if todos_block:
|
|
948
1099
|
messages.append(todos_block)
|
|
949
|
-
return messages
|
|
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
|
+
|
|
950
1135
|
|
|
951
1136
|
# Chat callbacks
|
|
952
1137
|
@app.callback(
|
|
@@ -960,10 +1145,11 @@ def display_initial_messages(history, theme):
|
|
|
960
1145
|
[State("chat-input", "value"),
|
|
961
1146
|
State("chat-history", "data"),
|
|
962
1147
|
State("theme-store", "data"),
|
|
963
|
-
State("current-workspace-path", "data")
|
|
1148
|
+
State("current-workspace-path", "data"),
|
|
1149
|
+
State("session-id", "data")],
|
|
964
1150
|
prevent_initial_call=True
|
|
965
1151
|
)
|
|
966
|
-
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):
|
|
967
1153
|
"""Phase 1: Immediately show user message and start agent."""
|
|
968
1154
|
if not message or not message.strip():
|
|
969
1155
|
raise PreventUpdate
|
|
@@ -992,11 +1178,19 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
|
|
|
992
1178
|
|
|
993
1179
|
messages.append(format_loading(colors))
|
|
994
1180
|
|
|
995
|
-
# Calculate
|
|
996
|
-
|
|
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)
|
|
997
1191
|
|
|
998
1192
|
# Start agent in background with workspace context
|
|
999
|
-
call_agent(message, workspace_path=
|
|
1193
|
+
call_agent(message, workspace_path=workspace_full_path, session_id=session_id)
|
|
1000
1194
|
|
|
1001
1195
|
# Enable polling
|
|
1002
1196
|
return messages, history, "", message, False
|
|
@@ -1005,14 +1199,16 @@ def handle_send_immediate(n_clicks, n_submit, message, history, theme, current_w
|
|
|
1005
1199
|
@app.callback(
|
|
1006
1200
|
[Output("chat-messages", "children", allow_duplicate=True),
|
|
1007
1201
|
Output("chat-history", "data", allow_duplicate=True),
|
|
1008
|
-
Output("poll-interval", "disabled", allow_duplicate=True)
|
|
1202
|
+
Output("poll-interval", "disabled", allow_duplicate=True),
|
|
1203
|
+
Output("skip-history-render", "data", allow_duplicate=True)],
|
|
1009
1204
|
Input("poll-interval", "n_intervals"),
|
|
1010
1205
|
[State("chat-history", "data"),
|
|
1011
1206
|
State("pending-message", "data"),
|
|
1012
|
-
State("theme-store", "data")
|
|
1207
|
+
State("theme-store", "data"),
|
|
1208
|
+
State("session-id", "data")],
|
|
1013
1209
|
prevent_initial_call=True
|
|
1014
1210
|
)
|
|
1015
|
-
def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
1211
|
+
def poll_agent_updates(n_intervals, history, pending_message, theme, session_id):
|
|
1016
1212
|
"""Poll for agent updates and display them in real-time.
|
|
1017
1213
|
|
|
1018
1214
|
Tool calls are stored in history and persist across turns.
|
|
@@ -1020,7 +1216,7 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1020
1216
|
- {"role": "user", "content": "..."} - user message
|
|
1021
1217
|
- {"role": "assistant", "content": "...", "tool_calls": [...]} - assistant message with tool calls
|
|
1022
1218
|
"""
|
|
1023
|
-
state = get_agent_state()
|
|
1219
|
+
state = get_agent_state(session_id)
|
|
1024
1220
|
history = history or []
|
|
1025
1221
|
colors = get_colors(theme or "light")
|
|
1026
1222
|
|
|
@@ -1069,7 +1265,7 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1069
1265
|
messages.append(interrupt_block)
|
|
1070
1266
|
|
|
1071
1267
|
# Disable polling - wait for user to respond to interrupt
|
|
1072
|
-
return messages, no_update, True
|
|
1268
|
+
return messages, no_update, True, no_update
|
|
1073
1269
|
|
|
1074
1270
|
# Check if agent is done
|
|
1075
1271
|
if not state["running"]:
|
|
@@ -1115,8 +1311,8 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1115
1311
|
if todos_block:
|
|
1116
1312
|
final_messages.append(todos_block)
|
|
1117
1313
|
|
|
1118
|
-
# Disable polling
|
|
1119
|
-
return final_messages, history, True
|
|
1314
|
+
# Disable polling, set skip flag to prevent display_initial_messages from re-rendering
|
|
1315
|
+
return final_messages, history, True, True
|
|
1120
1316
|
else:
|
|
1121
1317
|
# Agent still running - show loading with current thinking/tool_calls/todos
|
|
1122
1318
|
messages = render_history_messages(history)
|
|
@@ -1142,19 +1338,20 @@ def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
|
1142
1338
|
# Add loading indicator
|
|
1143
1339
|
messages.append(format_loading(colors))
|
|
1144
1340
|
|
|
1145
|
-
# Continue polling
|
|
1146
|
-
return messages, no_update, False
|
|
1341
|
+
# Continue polling, no skip flag needed
|
|
1342
|
+
return messages, no_update, False, no_update
|
|
1147
1343
|
|
|
1148
1344
|
|
|
1149
1345
|
# Stop button visibility - show when agent is running
|
|
1150
1346
|
@app.callback(
|
|
1151
1347
|
Output("stop-btn", "style"),
|
|
1152
1348
|
Input("poll-interval", "n_intervals"),
|
|
1349
|
+
State("session-id", "data"),
|
|
1153
1350
|
prevent_initial_call=True
|
|
1154
1351
|
)
|
|
1155
|
-
def update_stop_button_visibility(n_intervals):
|
|
1352
|
+
def update_stop_button_visibility(n_intervals, session_id):
|
|
1156
1353
|
"""Show stop button when agent is running, hide otherwise."""
|
|
1157
|
-
state = get_agent_state()
|
|
1354
|
+
state = get_agent_state(session_id)
|
|
1158
1355
|
if state.get("running"):
|
|
1159
1356
|
return {} # Show button (remove display:none)
|
|
1160
1357
|
else:
|
|
@@ -1167,10 +1364,11 @@ def update_stop_button_visibility(n_intervals):
|
|
|
1167
1364
|
Output("poll-interval", "disabled", allow_duplicate=True)],
|
|
1168
1365
|
Input("stop-btn", "n_clicks"),
|
|
1169
1366
|
[State("chat-history", "data"),
|
|
1170
|
-
State("theme-store", "data")
|
|
1367
|
+
State("theme-store", "data"),
|
|
1368
|
+
State("session-id", "data")],
|
|
1171
1369
|
prevent_initial_call=True
|
|
1172
1370
|
)
|
|
1173
|
-
def handle_stop_button(n_clicks, history, theme):
|
|
1371
|
+
def handle_stop_button(n_clicks, history, theme, session_id):
|
|
1174
1372
|
"""Handle stop button click to stop agent execution."""
|
|
1175
1373
|
if not n_clicks:
|
|
1176
1374
|
raise PreventUpdate
|
|
@@ -1179,7 +1377,7 @@ def handle_stop_button(n_clicks, history, theme):
|
|
|
1179
1377
|
history = history or []
|
|
1180
1378
|
|
|
1181
1379
|
# Request the agent to stop
|
|
1182
|
-
request_agent_stop()
|
|
1380
|
+
request_agent_stop(session_id)
|
|
1183
1381
|
|
|
1184
1382
|
# Render current messages with a stopping indicator
|
|
1185
1383
|
def render_history_messages(history):
|
|
@@ -1221,10 +1419,11 @@ def handle_stop_button(n_clicks, history, theme):
|
|
|
1221
1419
|
Input("interrupt-edit-btn", "n_clicks")],
|
|
1222
1420
|
[State("interrupt-input", "value"),
|
|
1223
1421
|
State("chat-history", "data"),
|
|
1224
|
-
State("theme-store", "data")
|
|
1422
|
+
State("theme-store", "data"),
|
|
1423
|
+
State("session-id", "data")],
|
|
1225
1424
|
prevent_initial_call=True
|
|
1226
1425
|
)
|
|
1227
|
-
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):
|
|
1228
1427
|
"""Handle user response to an interrupt.
|
|
1229
1428
|
|
|
1230
1429
|
Note: Click parameters are required for Dash callback inputs but we use
|
|
@@ -1266,7 +1465,7 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1266
1465
|
raise PreventUpdate
|
|
1267
1466
|
|
|
1268
1467
|
# Resume the agent with the user's decision
|
|
1269
|
-
resume_agent_from_interrupt(decision, action)
|
|
1468
|
+
resume_agent_from_interrupt(decision, action, session_id=session_id)
|
|
1270
1469
|
|
|
1271
1470
|
# Show loading state while agent resumes
|
|
1272
1471
|
messages = []
|
|
@@ -1303,16 +1502,20 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
|
|
|
1303
1502
|
State({"type": "folder-children", "path": ALL}, "style"),
|
|
1304
1503
|
State({"type": "folder-icon", "path": ALL}, "style"),
|
|
1305
1504
|
State({"type": "folder-children", "path": ALL}, "children"),
|
|
1306
|
-
State("theme-store", "data")
|
|
1505
|
+
State("theme-store", "data"),
|
|
1506
|
+
State("session-id", "data")],
|
|
1307
1507
|
prevent_initial_call=True
|
|
1308
1508
|
)
|
|
1309
|
-
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):
|
|
1310
1510
|
"""Toggle folder expansion and lazy load contents if needed."""
|
|
1311
1511
|
ctx = callback_context
|
|
1312
1512
|
if not ctx.triggered or not any(n_clicks):
|
|
1313
1513
|
raise PreventUpdate
|
|
1314
1514
|
|
|
1315
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)
|
|
1316
1519
|
triggered = ctx.triggered[0]["prop_id"]
|
|
1317
1520
|
try:
|
|
1318
1521
|
id_str = triggered.rsplit(".", 1)[0]
|
|
@@ -1354,7 +1557,7 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
|
|
|
1354
1557
|
current_content[0].get("props", {}).get("children") == "Loading..."):
|
|
1355
1558
|
# Load folder contents using real path
|
|
1356
1559
|
try:
|
|
1357
|
-
folder_items = load_folder_contents(folder_rel_path,
|
|
1560
|
+
folder_items = load_folder_contents(folder_rel_path, workspace_root)
|
|
1358
1561
|
loaded_content = render_file_tree(folder_items, colors, STYLES,
|
|
1359
1562
|
level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
|
|
1360
1563
|
parent_path=folder_rel_path)
|
|
@@ -1409,10 +1612,11 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
|
|
|
1409
1612
|
State({"type": "folder-select", "path": ALL}, "data-folderpath"),
|
|
1410
1613
|
State({"type": "folder-select", "path": ALL}, "n_clicks"),
|
|
1411
1614
|
State("current-workspace-path", "data"),
|
|
1412
|
-
State("theme-store", "data")
|
|
1615
|
+
State("theme-store", "data"),
|
|
1616
|
+
State("session-id", "data")],
|
|
1413
1617
|
prevent_initial_call=True
|
|
1414
1618
|
)
|
|
1415
|
-
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):
|
|
1416
1620
|
"""Enter a folder (double-click) or navigate via breadcrumb."""
|
|
1417
1621
|
ctx = callback_context
|
|
1418
1622
|
if not ctx.triggered:
|
|
@@ -1453,7 +1657,6 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
|
|
|
1453
1657
|
for i, folder_id in enumerate(folder_ids):
|
|
1454
1658
|
if folder_id["path"] == clicked_path:
|
|
1455
1659
|
current_clicks = folder_clicks[i] if i < len(folder_clicks) else 0
|
|
1456
|
-
previous_clicks = prev_clicks[i] if i < len(prev_clicks) else 0
|
|
1457
1660
|
|
|
1458
1661
|
# Only enter on double-click (clicks increased and is even number >= 2)
|
|
1459
1662
|
if current_clicks and current_clicks >= 2 and current_clicks % 2 == 0:
|
|
@@ -1508,8 +1711,14 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
|
|
|
1508
1711
|
)
|
|
1509
1712
|
)
|
|
1510
1713
|
|
|
1714
|
+
# Get workspace for this session (virtual or physical)
|
|
1715
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1716
|
+
|
|
1511
1717
|
# Calculate the actual workspace path
|
|
1512
|
-
|
|
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
|
|
1513
1722
|
|
|
1514
1723
|
# Render new file tree
|
|
1515
1724
|
file_tree = render_file_tree(
|
|
@@ -1530,10 +1739,11 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
|
|
|
1530
1739
|
Input({"type": "file-item", "path": ALL}, "n_clicks"),
|
|
1531
1740
|
[State({"type": "file-item", "path": ALL}, "id"),
|
|
1532
1741
|
State("file-click-tracker", "data"),
|
|
1533
|
-
State("theme-store", "data")
|
|
1742
|
+
State("theme-store", "data"),
|
|
1743
|
+
State("session-id", "data")],
|
|
1534
1744
|
prevent_initial_call=True
|
|
1535
1745
|
)
|
|
1536
|
-
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):
|
|
1537
1747
|
"""Open file in modal - only on actual new clicks."""
|
|
1538
1748
|
ctx = callback_context
|
|
1539
1749
|
|
|
@@ -1581,13 +1791,19 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
|
|
|
1581
1791
|
# Still need to return updated tracker to avoid stale state
|
|
1582
1792
|
raise PreventUpdate
|
|
1583
1793
|
|
|
1794
|
+
# Get workspace for this session (virtual or physical)
|
|
1795
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1796
|
+
|
|
1584
1797
|
# Verify file exists and is a file
|
|
1585
|
-
|
|
1798
|
+
if USE_VIRTUAL_FS:
|
|
1799
|
+
full_path = workspace_root.path(file_path)
|
|
1800
|
+
else:
|
|
1801
|
+
full_path = workspace_root / file_path
|
|
1586
1802
|
if not full_path.exists() or not full_path.is_file():
|
|
1587
1803
|
raise PreventUpdate
|
|
1588
1804
|
|
|
1589
1805
|
colors = get_colors(theme or "light")
|
|
1590
|
-
content, is_text, error = read_file_content(
|
|
1806
|
+
content, is_text, error = read_file_content(workspace_root, file_path)
|
|
1591
1807
|
filename = Path(file_path).name
|
|
1592
1808
|
|
|
1593
1809
|
if is_text and content:
|
|
@@ -1626,10 +1842,11 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
|
|
|
1626
1842
|
@app.callback(
|
|
1627
1843
|
Output("file-download", "data", allow_duplicate=True),
|
|
1628
1844
|
Input("modal-download-btn", "n_clicks"),
|
|
1629
|
-
State("file-to-view", "data"),
|
|
1845
|
+
[State("file-to-view", "data"),
|
|
1846
|
+
State("session-id", "data")],
|
|
1630
1847
|
prevent_initial_call=True
|
|
1631
1848
|
)
|
|
1632
|
-
def download_from_modal(n_clicks, file_path):
|
|
1849
|
+
def download_from_modal(n_clicks, file_path, session_id):
|
|
1633
1850
|
"""Download file from modal."""
|
|
1634
1851
|
ctx = callback_context
|
|
1635
1852
|
if not ctx.triggered:
|
|
@@ -1643,7 +1860,10 @@ def download_from_modal(n_clicks, file_path):
|
|
|
1643
1860
|
if not n_clicks or not file_path:
|
|
1644
1861
|
raise PreventUpdate
|
|
1645
1862
|
|
|
1646
|
-
|
|
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)
|
|
1647
1867
|
if not b64:
|
|
1648
1868
|
raise PreventUpdate
|
|
1649
1869
|
|
|
@@ -1660,10 +1880,15 @@ def open_terminal(n_clicks):
|
|
|
1660
1880
|
"""Open system terminal at workspace directory."""
|
|
1661
1881
|
if not n_clicks:
|
|
1662
1882
|
raise PreventUpdate
|
|
1663
|
-
|
|
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
|
+
|
|
1664
1889
|
workspace_path = str(WORKSPACE_ROOT)
|
|
1665
1890
|
system = platform.system()
|
|
1666
|
-
|
|
1891
|
+
|
|
1667
1892
|
try:
|
|
1668
1893
|
if system == "Darwin": # macOS
|
|
1669
1894
|
subprocess.Popen(["open", "-a", "Terminal", workspace_path])
|
|
@@ -1685,7 +1910,7 @@ def open_terminal(n_clicks):
|
|
|
1685
1910
|
continue
|
|
1686
1911
|
except Exception as e:
|
|
1687
1912
|
print(f"Failed to open terminal: {e}")
|
|
1688
|
-
|
|
1913
|
+
|
|
1689
1914
|
raise PreventUpdate
|
|
1690
1915
|
|
|
1691
1916
|
|
|
@@ -1696,27 +1921,31 @@ def open_terminal(n_clicks):
|
|
|
1696
1921
|
Input("refresh-btn", "n_clicks"),
|
|
1697
1922
|
[State("current-workspace-path", "data"),
|
|
1698
1923
|
State("theme-store", "data"),
|
|
1699
|
-
State("collapsed-canvas-items", "data")
|
|
1924
|
+
State("collapsed-canvas-items", "data"),
|
|
1925
|
+
State("session-id", "data")],
|
|
1700
1926
|
prevent_initial_call=True
|
|
1701
1927
|
)
|
|
1702
|
-
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
|
|
1928
|
+
def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id):
|
|
1703
1929
|
"""Refresh both file tree and canvas content."""
|
|
1704
|
-
global _agent_state
|
|
1705
1930
|
colors = get_colors(theme or "light")
|
|
1706
1931
|
collapsed_ids = collapsed_ids or []
|
|
1707
1932
|
|
|
1933
|
+
# Get workspace for this session (virtual or physical)
|
|
1934
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
1935
|
+
|
|
1708
1936
|
# Calculate current workspace directory
|
|
1709
|
-
|
|
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
|
|
1710
1941
|
|
|
1711
1942
|
# Refresh file tree for current workspace
|
|
1712
1943
|
file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
|
|
1713
1944
|
|
|
1714
|
-
#
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
with _agent_state_lock:
|
|
1719
|
-
_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", [])
|
|
1720
1949
|
|
|
1721
1950
|
# Render the canvas items with preserved collapsed state
|
|
1722
1951
|
canvas_content = render_canvas_items(canvas_items, colors, collapsed_ids)
|
|
@@ -1730,17 +1959,25 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids):
|
|
|
1730
1959
|
Input("file-upload-sidebar", "contents"),
|
|
1731
1960
|
[State("file-upload-sidebar", "filename"),
|
|
1732
1961
|
State("current-workspace-path", "data"),
|
|
1733
|
-
State("theme-store", "data")
|
|
1962
|
+
State("theme-store", "data"),
|
|
1963
|
+
State("session-id", "data")],
|
|
1734
1964
|
prevent_initial_call=True
|
|
1735
1965
|
)
|
|
1736
|
-
def handle_sidebar_upload(contents, filenames, current_workspace, theme):
|
|
1966
|
+
def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id):
|
|
1737
1967
|
"""Handle file uploads from sidebar button to current workspace."""
|
|
1738
1968
|
if not contents:
|
|
1739
1969
|
raise PreventUpdate
|
|
1740
1970
|
|
|
1741
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
|
+
|
|
1742
1976
|
# Calculate current workspace directory
|
|
1743
|
-
|
|
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
|
|
1744
1981
|
|
|
1745
1982
|
for content, filename in zip(contents, filenames):
|
|
1746
1983
|
try:
|
|
@@ -1796,10 +2033,11 @@ def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_op
|
|
|
1796
2033
|
Input("confirm-folder-btn", "n_clicks"),
|
|
1797
2034
|
[State("new-folder-name", "value"),
|
|
1798
2035
|
State("current-workspace-path", "data"),
|
|
1799
|
-
State("theme-store", "data")
|
|
2036
|
+
State("theme-store", "data"),
|
|
2037
|
+
State("session-id", "data")],
|
|
1800
2038
|
prevent_initial_call=True
|
|
1801
2039
|
)
|
|
1802
|
-
def create_folder(n_clicks, folder_name, current_workspace, theme):
|
|
2040
|
+
def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
|
|
1803
2041
|
"""Create a new folder in the current workspace directory."""
|
|
1804
2042
|
if not n_clicks:
|
|
1805
2043
|
raise PreventUpdate
|
|
@@ -1816,8 +2054,15 @@ def create_folder(n_clicks, folder_name, current_workspace, theme):
|
|
|
1816
2054
|
if any(char in folder_name for char in invalid_chars):
|
|
1817
2055
|
return no_update, f"Folder name cannot contain: {' '.join(invalid_chars)}", no_update
|
|
1818
2056
|
|
|
2057
|
+
# Get workspace for this session (virtual or physical)
|
|
2058
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
2059
|
+
|
|
1819
2060
|
# Calculate current workspace directory
|
|
1820
|
-
|
|
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
|
+
|
|
1821
2066
|
folder_path = current_workspace_dir / folder_name
|
|
1822
2067
|
|
|
1823
2068
|
if folder_path.exists():
|
|
@@ -1889,12 +2134,13 @@ def toggle_view(view_value):
|
|
|
1889
2134
|
[Input("poll-interval", "n_intervals"),
|
|
1890
2135
|
Input("sidebar-view-toggle", "value")],
|
|
1891
2136
|
[State("theme-store", "data"),
|
|
1892
|
-
State("collapsed-canvas-items", "data")
|
|
2137
|
+
State("collapsed-canvas-items", "data"),
|
|
2138
|
+
State("session-id", "data")],
|
|
1893
2139
|
prevent_initial_call=False
|
|
1894
2140
|
)
|
|
1895
|
-
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):
|
|
1896
2142
|
"""Update canvas content from agent state."""
|
|
1897
|
-
state = get_agent_state()
|
|
2143
|
+
state = get_agent_state(session_id)
|
|
1898
2144
|
canvas_items = state.get("canvas", [])
|
|
1899
2145
|
colors = get_colors(theme or "light")
|
|
1900
2146
|
collapsed_ids = collapsed_ids or []
|
|
@@ -1903,6 +2149,50 @@ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids):
|
|
|
1903
2149
|
return render_canvas_items(canvas_items, colors, collapsed_ids)
|
|
1904
2150
|
|
|
1905
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
|
+
|
|
1906
2196
|
|
|
1907
2197
|
# Open clear canvas confirmation modal
|
|
1908
2198
|
@app.callback(
|
|
@@ -1924,10 +2214,11 @@ def open_clear_canvas_modal(n_clicks):
|
|
|
1924
2214
|
Output("collapsed-canvas-items", "data", allow_duplicate=True)],
|
|
1925
2215
|
[Input("confirm-clear-canvas-btn", "n_clicks"),
|
|
1926
2216
|
Input("cancel-clear-canvas-btn", "n_clicks")],
|
|
1927
|
-
[State("theme-store", "data")
|
|
2217
|
+
[State("theme-store", "data"),
|
|
2218
|
+
State("session-id", "data")],
|
|
1928
2219
|
prevent_initial_call=True
|
|
1929
2220
|
)
|
|
1930
|
-
def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
|
|
2221
|
+
def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme, session_id):
|
|
1931
2222
|
"""Handle the clear canvas confirmation - either clear or cancel."""
|
|
1932
2223
|
ctx = callback_context
|
|
1933
2224
|
if not ctx.triggered:
|
|
@@ -1948,15 +2239,31 @@ def handle_clear_canvas_confirmation(confirm_clicks, cancel_clicks, theme):
|
|
|
1948
2239
|
|
|
1949
2240
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1950
2241
|
|
|
2242
|
+
# Get workspace for this session (virtual or physical)
|
|
2243
|
+
workspace_root = get_workspace_for_session(session_id)
|
|
2244
|
+
|
|
1951
2245
|
# Archive .canvas folder if it exists (contains canvas.md and all assets)
|
|
1952
|
-
|
|
1953
|
-
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
|
|
1954
2258
|
try:
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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()
|
|
1958
2265
|
except Exception as e:
|
|
1959
|
-
print(f"Failed to
|
|
2266
|
+
print(f"Failed to clear virtual canvas: {e}")
|
|
1960
2267
|
|
|
1961
2268
|
# Clear canvas in state
|
|
1962
2269
|
with _agent_state_lock:
|
|
@@ -2112,10 +2419,11 @@ def open_delete_confirmation(all_clicks, all_ids):
|
|
|
2112
2419
|
Input("cancel-delete-canvas-btn", "n_clicks")],
|
|
2113
2420
|
[State("delete-canvas-item-id", "data"),
|
|
2114
2421
|
State("theme-store", "data"),
|
|
2115
|
-
State("collapsed-canvas-items", "data")
|
|
2422
|
+
State("collapsed-canvas-items", "data"),
|
|
2423
|
+
State("session-id", "data")],
|
|
2116
2424
|
prevent_initial_call=True
|
|
2117
2425
|
)
|
|
2118
|
-
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):
|
|
2119
2427
|
"""Handle the delete confirmation - either delete or cancel."""
|
|
2120
2428
|
ctx = callback_context
|
|
2121
2429
|
if not ctx.triggered:
|
|
@@ -2131,23 +2439,34 @@ def handle_delete_confirmation(confirm_clicks, cancel_clicks, item_id, theme, co
|
|
|
2131
2439
|
if not confirm_clicks or not item_id:
|
|
2132
2440
|
raise PreventUpdate
|
|
2133
2441
|
|
|
2134
|
-
global _agent_state
|
|
2135
2442
|
colors = get_colors(theme or "light")
|
|
2136
2443
|
collapsed_ids = collapsed_ids or []
|
|
2137
2444
|
|
|
2138
|
-
#
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
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()
|
|
2145
2464
|
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
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}")
|
|
2151
2470
|
|
|
2152
2471
|
# Remove deleted item from collapsed_ids if present
|
|
2153
2472
|
new_collapsed_ids = [cid for cid in collapsed_ids if cid != item_id]
|
|
@@ -2221,7 +2540,8 @@ def run_app(
|
|
|
2221
2540
|
title=None,
|
|
2222
2541
|
subtitle=None,
|
|
2223
2542
|
welcome_message=None,
|
|
2224
|
-
config_file=None
|
|
2543
|
+
config_file=None,
|
|
2544
|
+
virtual_fs=None
|
|
2225
2545
|
):
|
|
2226
2546
|
"""
|
|
2227
2547
|
Run DeepAgent Dash programmatically.
|
|
@@ -2243,6 +2563,9 @@ def run_app(
|
|
|
2243
2563
|
subtitle (str, optional): Application subtitle
|
|
2244
2564
|
welcome_message (str, optional): Welcome message shown on startup (supports markdown)
|
|
2245
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.
|
|
2246
2569
|
|
|
2247
2570
|
Returns:
|
|
2248
2571
|
int: Exit code (0 for success, non-zero for error)
|
|
@@ -2262,7 +2585,10 @@ def run_app(
|
|
|
2262
2585
|
>>> # Without agent (manual mode)
|
|
2263
2586
|
>>> run_app(workspace="~/my-workspace", debug=True)
|
|
2264
2587
|
"""
|
|
2265
|
-
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
|
|
2266
2592
|
|
|
2267
2593
|
# Load config file if specified and exists
|
|
2268
2594
|
config_module = None
|
|
@@ -2332,16 +2658,22 @@ def run_app(
|
|
|
2332
2658
|
# Use default config agent
|
|
2333
2659
|
agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
|
|
2334
2660
|
|
|
2335
|
-
#
|
|
2336
|
-
|
|
2661
|
+
# Update global agent state
|
|
2662
|
+
global _agent_state
|
|
2337
2663
|
|
|
2338
|
-
#
|
|
2339
|
-
|
|
2340
|
-
|
|
2664
|
+
# Ensure workspace exists (only for physical filesystem mode)
|
|
2665
|
+
if not USE_VIRTUAL_FS:
|
|
2666
|
+
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
2341
2667
|
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
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"] = []
|
|
2345
2677
|
|
|
2346
2678
|
# Create a mock args object for compatibility with existing code
|
|
2347
2679
|
class Args:
|
|
@@ -2354,9 +2686,13 @@ def run_app(
|
|
|
2354
2686
|
print("\n" + "="*50)
|
|
2355
2687
|
print(f" {APP_TITLE}")
|
|
2356
2688
|
print("="*50)
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
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})")
|
|
2360
2696
|
print(f" Agent: {'Ready' if agent else 'Not available'}")
|
|
2361
2697
|
if agent_spec:
|
|
2362
2698
|
print(f" (from CLI: --agent {agent_spec})")
|
|
@@ -2372,25 +2708,4 @@ def run_app(
|
|
|
2372
2708
|
return 0
|
|
2373
2709
|
except Exception as e:
|
|
2374
2710
|
print(f"\n❌ Error running app: {e}")
|
|
2375
|
-
return 1
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
# =============================================================================
|
|
2379
|
-
# MAIN - BACKWARDS COMPATIBILITY
|
|
2380
|
-
# =============================================================================
|
|
2381
|
-
|
|
2382
|
-
if __name__ == "__main__":
|
|
2383
|
-
# Parse CLI arguments
|
|
2384
|
-
args = parse_args()
|
|
2385
|
-
|
|
2386
|
-
# When run directly (not as package), use original CLI arg parsing
|
|
2387
|
-
sys.exit(run_app(
|
|
2388
|
-
workspace=args.workspace if args.workspace else None,
|
|
2389
|
-
agent_spec=args.agent if args.agent else None,
|
|
2390
|
-
port=args.port if args.port else None,
|
|
2391
|
-
host=args.host if args.host else None,
|
|
2392
|
-
debug=args.debug if args.debug else (not args.no_debug if args.no_debug else None),
|
|
2393
|
-
title=args.title if args.title else None,
|
|
2394
|
-
subtitle=args.subtitle if args.subtitle else None,
|
|
2395
|
-
config_file=args.config if args.config else None
|
|
2396
|
-
))
|
|
2711
|
+
return 1
|