cowork-dash 0.1.6__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/canvas.py CHANGED
@@ -1,4 +1,8 @@
1
- """Canvas utilities for parsing, exporting, and loading canvas objects."""
1
+ """Canvas utilities for parsing, exporting, and loading canvas objects.
2
+
3
+ Supports both physical filesystem (Path) and virtual filesystem (VirtualFilesystem)
4
+ for session isolation in multi-user deployments.
5
+ """
2
6
 
3
7
  import io
4
8
  import json
@@ -6,9 +10,24 @@ import base64
6
10
  import re
7
11
  import uuid
8
12
  from pathlib import Path
9
- from typing import Any, Dict, List, Optional
13
+ from typing import Any, Dict, List, Optional, Union
10
14
  from datetime import datetime
11
15
 
16
+ from .virtual_fs import VirtualFilesystem, VirtualPath
17
+
18
+
19
+ # Type alias for paths that work with both physical and virtual filesystems
20
+ AnyPath = Union[Path, VirtualPath]
21
+ AnyRoot = Union[Path, VirtualFilesystem]
22
+
23
+
24
+ def _get_path(root: AnyRoot, path: str = "") -> AnyPath:
25
+ """Get a path object from root, handling both Path and VirtualFilesystem."""
26
+ if isinstance(root, VirtualFilesystem):
27
+ return root.path(path) if path else root.root
28
+ else:
29
+ return root / path if path else root
30
+
12
31
 
13
32
  def generate_canvas_id() -> str:
14
33
  """Generate a unique ID for a canvas item."""
@@ -17,7 +36,7 @@ def generate_canvas_id() -> str:
17
36
 
18
37
  def parse_canvas_object(
19
38
  obj: Any,
20
- workspace_root: Path,
39
+ workspace_root: AnyRoot,
21
40
  title: Optional[str] = None,
22
41
  item_id: Optional[str] = None
23
42
  ) -> Dict[str, Any]:
@@ -25,7 +44,7 @@ def parse_canvas_object(
25
44
 
26
45
  Args:
27
46
  obj: The Python object to parse (DataFrame, Figure, Image, str, etc.)
28
- workspace_root: Path to the workspace root directory
47
+ workspace_root: Path to the workspace root directory (Path or VirtualFilesystem)
29
48
  title: Optional title for the canvas item
30
49
  item_id: Optional ID for the canvas item (auto-generated if not provided)
31
50
 
@@ -53,7 +72,7 @@ def parse_canvas_object(
53
72
  return result
54
73
 
55
74
  # Ensure .canvas directory exists
56
- canvas_dir = workspace_root / ".canvas"
75
+ canvas_dir = _get_path(workspace_root, ".canvas")
57
76
  canvas_dir.mkdir(exist_ok=True)
58
77
 
59
78
  # Pandas DataFrame - keep inline
@@ -71,15 +90,19 @@ def parse_canvas_object(
71
90
  filename = f"matplotlib_{timestamp}.png"
72
91
  filepath = canvas_dir / filename
73
92
 
74
- obj.savefig(filepath, format='png', bbox_inches='tight', dpi=100)
75
-
76
- # Also store base64 for in-memory rendering
93
+ # Save to buffer first, then to file
77
94
  buf = io.BytesIO()
78
95
  obj.savefig(buf, format='png', bbox_inches='tight', dpi=100)
79
96
  buf.seek(0)
80
- img_base64 = base64.b64encode(buf.read()).decode('utf-8')
97
+ img_data = buf.read()
81
98
  buf.close()
82
99
 
100
+ # Write to file (virtual or physical)
101
+ filepath.write_bytes(img_data)
102
+
103
+ # Also store base64 for in-memory rendering
104
+ img_base64 = base64.b64encode(img_data).decode('utf-8')
105
+
83
106
  return add_metadata({
84
107
  "type": "matplotlib",
85
108
  "file": filename, # Relative to .canvas/ directory where canvas.md lives
@@ -107,15 +130,19 @@ def parse_canvas_object(
107
130
  filename = f"image_{timestamp}.png"
108
131
  filepath = canvas_dir / filename
109
132
 
110
- obj.save(filepath, format='PNG')
111
-
112
- # Also store base64 for in-memory rendering
133
+ # Save to buffer first
113
134
  buf = io.BytesIO()
114
135
  obj.save(buf, format='PNG')
115
136
  buf.seek(0)
116
- img_base64 = base64.b64encode(buf.read()).decode('utf-8')
137
+ img_data = buf.read()
117
138
  buf.close()
118
139
 
140
+ # Write to file (virtual or physical)
141
+ filepath.write_bytes(img_data)
142
+
143
+ # Also store base64 for in-memory rendering
144
+ img_base64 = base64.b64encode(img_data).decode('utf-8')
145
+
119
146
  return add_metadata({
120
147
  "type": "image",
121
148
  "file": filename, # Relative to .canvas/ directory where canvas.md lives
@@ -162,14 +189,29 @@ def parse_canvas_object(
162
189
  })
163
190
 
164
191
 
165
- def export_canvas_to_markdown(canvas_items: List[Dict], workspace_root: Path, output_path: str = None):
166
- """Export canvas to markdown file with file references and metadata."""
192
+ def export_canvas_to_markdown(
193
+ canvas_items: List[Dict],
194
+ workspace_root: AnyRoot,
195
+ output_path: str = None
196
+ ) -> str:
197
+ """Export canvas to markdown file with file references and metadata.
198
+
199
+ Args:
200
+ canvas_items: List of parsed canvas items
201
+ workspace_root: Path to the workspace root directory (Path or VirtualFilesystem)
202
+ output_path: Optional custom output path (relative to workspace_root/.canvas/)
203
+
204
+ Returns:
205
+ Path to the output file
206
+ """
167
207
  # Ensure .canvas directory exists
168
- canvas_dir = workspace_root / ".canvas"
208
+ canvas_dir = _get_path(workspace_root, ".canvas")
169
209
  canvas_dir.mkdir(exist_ok=True)
170
210
 
171
211
  if not output_path:
172
- output_path = canvas_dir / "canvas.md"
212
+ output_file = canvas_dir / "canvas.md"
213
+ else:
214
+ output_file = _get_path(workspace_root, output_path)
173
215
 
174
216
  lines = [
175
217
  "# Canvas Export",
@@ -222,24 +264,37 @@ def export_canvas_to_markdown(canvas_items: List[Dict], workspace_root: Path, ou
222
264
  lines.append(f"\n```json\n{json.dumps(parsed.get('data'), indent=2)}\n```\n")
223
265
 
224
266
  # Write to file
225
- output_file = Path(output_path)
226
267
  output_file.write_text("\n".join(lines))
227
268
  return str(output_file)
228
269
 
229
270
 
230
- def load_canvas_from_markdown(workspace_root: Path, markdown_path: str = None) -> List[Dict]:
231
- """Load canvas from markdown file and referenced assets, preserving metadata."""
271
+ def load_canvas_from_markdown(
272
+ workspace_root: AnyRoot,
273
+ markdown_path: str = None
274
+ ) -> List[Dict]:
275
+ """Load canvas from markdown file and referenced assets, preserving metadata.
276
+
277
+ Args:
278
+ workspace_root: Path to the workspace root directory (Path or VirtualFilesystem)
279
+ markdown_path: Optional custom markdown file path
280
+
281
+ Returns:
282
+ List of parsed canvas items
283
+ """
232
284
  if not markdown_path:
233
- markdown_path = workspace_root / ".canvas" / "canvas.md"
285
+ canvas_md = _get_path(workspace_root, ".canvas/canvas.md")
234
286
  else:
235
- markdown_path = Path(markdown_path)
287
+ canvas_md = _get_path(workspace_root, markdown_path)
236
288
 
237
- if not markdown_path.exists():
289
+ if not canvas_md.exists():
238
290
  return []
239
291
 
240
- content = markdown_path.read_text()
292
+ content = canvas_md.read_text()
241
293
  canvas_items = []
242
294
 
295
+ # Get parent directory for loading referenced files
296
+ canvas_dir = canvas_md.parent
297
+
243
298
  # First, find all metadata comments to get item boundaries and metadata
244
299
  metadata_pattern = r'<!-- canvas-item: ({.*?}) -->'
245
300
  metadata_matches = list(re.finditer(metadata_pattern, content))
@@ -260,17 +315,21 @@ def load_canvas_from_markdown(workspace_root: Path, markdown_path: str = None) -
260
315
  end = len(content)
261
316
 
262
317
  item_content = content[start:end].strip()
263
- item = _parse_item_content(item_content, metadata, markdown_path)
318
+ item = _parse_item_content(item_content, metadata, canvas_dir)
264
319
  if item:
265
320
  canvas_items.append(item)
266
321
  else:
267
322
  # Fallback: legacy parsing without metadata (backwards compatibility)
268
- canvas_items = _parse_legacy_canvas(content, markdown_path)
323
+ canvas_items = _parse_legacy_canvas(content, canvas_dir)
269
324
 
270
325
  return canvas_items
271
326
 
272
327
 
273
- def _parse_item_content(content: str, metadata: Dict, markdown_path: Path) -> Optional[Dict]:
328
+ def _parse_item_content(
329
+ content: str,
330
+ metadata: Dict,
331
+ canvas_dir: AnyPath
332
+ ) -> Optional[Dict]:
274
333
  """Parse a single item's content given its metadata."""
275
334
  item_type = metadata.get("type", "markdown")
276
335
  item = {
@@ -297,7 +356,7 @@ def _parse_item_content(content: str, metadata: Dict, markdown_path: Path) -> Op
297
356
  match = re.search(r'```plotly\s*\n([^\n]+)\n```', content)
298
357
  if match:
299
358
  file_ref = match.group(1).strip()
300
- file_path = markdown_path.parent / file_ref
359
+ file_path = canvas_dir / file_ref
301
360
  if file_path.exists():
302
361
  item["file"] = file_ref
303
362
  item["data"] = json.loads(file_path.read_text())
@@ -308,10 +367,10 @@ def _parse_item_content(content: str, metadata: Dict, markdown_path: Path) -> Op
308
367
  if match:
309
368
  file_ref = match.group(1)
310
369
  if not file_ref.startswith('data:'):
311
- file_path = markdown_path.parent / file_ref
370
+ file_path = canvas_dir / file_ref
312
371
  if file_path.exists():
313
- with open(file_path, 'rb') as f:
314
- item["data"] = base64.b64encode(f.read()).decode('utf-8')
372
+ img_data = file_path.read_bytes()
373
+ item["data"] = base64.b64encode(img_data).decode('utf-8')
315
374
  item["file"] = file_ref
316
375
  item["type"] = "image" # Normalize type
317
376
  return item
@@ -332,7 +391,7 @@ def _parse_item_content(content: str, metadata: Dict, markdown_path: Path) -> Op
332
391
  return None
333
392
 
334
393
 
335
- def _parse_legacy_canvas(content: str, markdown_path: Path) -> List[Dict]:
394
+ def _parse_legacy_canvas(content: str, canvas_dir: AnyPath) -> List[Dict]:
336
395
  """Parse canvas without metadata comments (legacy format)."""
337
396
  canvas_items = []
338
397
  code_blocks = []
@@ -399,17 +458,17 @@ def _parse_legacy_canvas(content: str, markdown_path: Path) -> List[Dict]:
399
458
  item["data"] = block['content']
400
459
  canvas_items.append(item)
401
460
  elif block['type'] == 'plotly_file':
402
- file_path = markdown_path.parent / block['content']
461
+ file_path = canvas_dir / block['content']
403
462
  if file_path.exists():
404
463
  item["type"] = "plotly"
405
464
  item["file"] = block['content']
406
465
  item["data"] = json.loads(file_path.read_text())
407
466
  canvas_items.append(item)
408
467
  elif block['type'] == 'image_file':
409
- file_path = markdown_path.parent / block['content']
468
+ file_path = canvas_dir / block['content']
410
469
  if file_path.exists():
411
- with open(file_path, 'rb') as f:
412
- item["data"] = base64.b64encode(f.read()).decode('utf-8')
470
+ img_data = file_path.read_bytes()
471
+ item["data"] = base64.b64encode(img_data).decode('utf-8')
413
472
  item["type"] = "image"
414
473
  item["file"] = block['content']
415
474
  canvas_items.append(item)
@@ -429,4 +488,4 @@ def _parse_legacy_canvas(content: str, markdown_path: Path) -> List[Dict]:
429
488
  "data": remaining
430
489
  })
431
490
 
432
- return canvas_items
491
+ return canvas_items
cowork_dash/cli.py CHANGED
@@ -126,21 +126,21 @@ cowork-dash run --help
126
126
  """
127
127
  (project_dir / "README.md").write_text(readme)
128
128
 
129
- print(f"✓ Created project structure")
130
- print(f"✓ Created config.py")
131
- print(f"✓ Created workspace/")
132
- print(f"✓ Created .env.example")
133
- print(f"✓ Created .gitignore")
134
- print(f"✓ Created README.md")
129
+ print("✓ Created project structure")
130
+ print("✓ Created config.py")
131
+ print("✓ Created workspace/")
132
+ print("✓ Created .env.example")
133
+ print("✓ Created .gitignore")
134
+ print("✓ Created README.md")
135
135
  print(f"\n{'='*50}")
136
136
  print(f"🎉 Project '{name}' created successfully!")
137
137
  print(f"{'='*50}\n")
138
- print(f"Next steps:")
138
+ print("Next steps:")
139
139
  print(f" 1. cd {name}")
140
- print(f" 2. cp .env.example .env # If using DeepAgents")
141
- print(f" 3. Edit .env and add your ANTHROPIC_API_KEY")
142
- print(f" 4. Edit config.py to customize your agent")
143
- print(f" 5. cowork-dash run")
140
+ print(" 2. cp .env.example .env # If using DeepAgents")
141
+ print(" 3. Edit .env and add your ANTHROPIC_API_KEY")
142
+ print(" 4. Edit config.py to customize your agent")
143
+ print(" 5. cowork-dash run")
144
144
  print()
145
145
 
146
146
  return 0
@@ -151,6 +151,10 @@ def run_app_cli(args):
151
151
  # Import here to avoid loading Dash when just running init
152
152
  from .app import run_app
153
153
 
154
+ # Only pass virtual_fs if explicitly set via --virtual-fs flag
155
+ # Otherwise pass None to let env var / config take precedence
156
+ virtual_fs = True if args.virtual_fs else None
157
+
154
158
  return run_app(
155
159
  workspace=args.workspace,
156
160
  agent_spec=args.agent,
@@ -159,7 +163,8 @@ def run_app_cli(args):
159
163
  debug=args.debug,
160
164
  title=args.title,
161
165
  welcome_message=args.welcome_message,
162
- config_file=args.config
166
+ config_file=args.config,
167
+ virtual_fs=virtual_fs
163
168
  )
164
169
 
165
170
 
@@ -259,6 +264,12 @@ For more help: https://github.com/dkedar7/cowork-dash
259
264
  dest="welcome_message",
260
265
  help="Welcome message shown on startup (supports markdown)"
261
266
  )
267
+ run_parser.add_argument(
268
+ "--virtual-fs",
269
+ action="store_true",
270
+ dest="virtual_fs",
271
+ help="Use in-memory virtual filesystem (ephemeral, for multi-user isolation)"
272
+ )
262
273
 
263
274
  # Parse arguments
264
275
  args = parser.parse_args()
cowork_dash/components.py CHANGED
@@ -335,7 +335,6 @@ def format_interrupt(interrupt_data: Dict, colors: Dict):
335
335
  # Show action requests if any
336
336
  if action_requests:
337
337
  for i, action in enumerate(action_requests):
338
- action_type = action.get("type", "unknown")
339
338
  action_tool = action.get("tool", "")
340
339
  action_args = action.get("args", {})
341
340
 
cowork_dash/config.py CHANGED
@@ -107,3 +107,24 @@ _default_welcome = """This is your AI-powered workspace. I can help you write co
107
107
 
108
108
  Let's get started!"""
109
109
  WELCOME_MESSAGE = get_config("welcome_message", default=_default_welcome)
110
+
111
+ # Virtual filesystem mode (for multi-user deployments)
112
+ # Environment variable: DEEPAGENT_VIRTUAL_FS
113
+ # Accepts: true/1/yes to enable
114
+ # When enabled:
115
+ # - Each browser session gets isolated in-memory file storage
116
+ # - Files, canvas, and uploads are not shared between sessions
117
+ # - All data is ephemeral (cleared when session ends)
118
+ # When disabled (default):
119
+ # - All sessions share the same workspace directory on disk
120
+ # - Files persist on disk
121
+ VIRTUAL_FS = get_config(
122
+ "virtual_fs",
123
+ default=False,
124
+ type_cast=lambda x: str(x).lower() in ("true", "1", "yes")
125
+ )
126
+
127
+ # Session timeout in seconds (only used when VIRTUAL_FS is True)
128
+ # Environment variable: DEEPAGENT_SESSION_TIMEOUT
129
+ # Default: 3600 (1 hour)
130
+ SESSION_TIMEOUT = get_config("session_timeout", default=3600, type_cast=int)
cowork_dash/file_utils.py CHANGED
@@ -1,10 +1,16 @@
1
- """File tree and file operations utilities."""
1
+ """File tree and file operations utilities.
2
+
3
+ Supports both physical filesystem (Path) and virtual filesystem (VirtualFilesystem)
4
+ for session isolation in multi-user deployments.
5
+ """
2
6
 
3
7
  import base64
4
- from pathlib import Path
5
- from typing import List, Dict, Tuple
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import List, Dict, Tuple, Union, Optional
6
10
  from dash import html
7
11
 
12
+ from .virtual_fs import VirtualFilesystem, VirtualPath
13
+
8
14
 
9
15
  TEXT_EXTENSIONS = {
10
16
  ".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".css", ".json", ".md", ".txt",
@@ -12,6 +18,10 @@ TEXT_EXTENSIONS = {
12
18
  ".gitignore", ".dockerignore", ".cfg", ".ini", ".conf", ".log"
13
19
  }
14
20
 
21
+ # Type alias for paths that work with both physical and virtual filesystems
22
+ AnyPath = Union[Path, VirtualPath]
23
+ AnyRoot = Union[Path, VirtualFilesystem]
24
+
15
25
 
16
26
  def is_text_file(filename: str) -> bool:
17
27
  """Check if a file can be viewed as text."""
@@ -19,25 +29,67 @@ def is_text_file(filename: str) -> bool:
19
29
  return ext in TEXT_EXTENSIONS or ext == ""
20
30
 
21
31
 
22
- def build_file_tree(root: Path, workspace_root: Path, lazy_load: bool = True) -> List[Dict]:
32
+ def _get_path(root: AnyRoot, path: str = "") -> AnyPath:
33
+ """Get a path object from root, handling both Path and VirtualFilesystem."""
34
+ if isinstance(root, VirtualFilesystem):
35
+ return root.path(path) if path else root.root
36
+ else:
37
+ return root / path if path else root
38
+
39
+
40
+ def _relative_path(path: AnyPath, root: AnyPath) -> str:
41
+ """Get relative path string."""
42
+ if isinstance(path, VirtualPath):
43
+ path_str = str(path)
44
+ root_str = str(root)
45
+ if path_str.startswith(root_str):
46
+ rel = path_str[len(root_str):].lstrip("/")
47
+ return rel or "."
48
+ return str(path)
49
+ else:
50
+ return str(path.relative_to(root))
51
+
52
+
53
+ def build_file_tree(
54
+ root: AnyPath,
55
+ workspace_root: AnyRoot,
56
+ lazy_load: bool = True
57
+ ) -> List[Dict]:
23
58
  """
24
59
  Build file tree structure.
25
60
 
26
61
  Args:
27
- root: Directory to scan
28
- workspace_root: Root workspace directory for relative paths
62
+ root: Directory to scan (Path or VirtualPath)
63
+ workspace_root: Root workspace directory for relative paths (Path or VirtualFilesystem)
29
64
  lazy_load: If True, only load immediate children (subdirs not expanded)
30
65
 
31
66
  Returns:
32
67
  List of file/folder items
33
68
  """
34
69
  items = []
70
+
71
+ # Get the root path object if workspace_root is a VirtualFilesystem
72
+ if isinstance(workspace_root, VirtualFilesystem):
73
+ workspace_root_path = workspace_root.root
74
+ else:
75
+ workspace_root_path = workspace_root
76
+
35
77
  try:
36
- entries = sorted(root.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
78
+ # Get entries from directory
79
+ if isinstance(root, VirtualPath):
80
+ entries = list(root.iterdir())
81
+ else:
82
+ entries = list(root.iterdir())
83
+
84
+ # Sort: directories first, then by name
85
+ entries = sorted(entries, key=lambda x: (not x.is_dir(), x.name.lower()))
86
+
37
87
  for entry in entries:
38
88
  if entry.name.startswith('.'):
39
89
  continue
40
- rel_path = str(entry.relative_to(workspace_root))
90
+
91
+ rel_path = _relative_path(entry, workspace_root_path)
92
+
41
93
  if entry.is_dir():
42
94
  # Count immediate children to show if folder is empty
43
95
  try:
@@ -60,23 +112,27 @@ def build_file_tree(root: Path, workspace_root: Path, lazy_load: bool = True) ->
60
112
  "path": rel_path,
61
113
  "viewable": is_text_file(entry.name)
62
114
  })
63
- except PermissionError:
115
+ except (PermissionError, FileNotFoundError):
64
116
  pass
117
+
65
118
  return items
66
119
 
67
120
 
68
- def load_folder_contents(folder_path: str, workspace_root: Path) -> List[Dict]:
121
+ def load_folder_contents(
122
+ folder_path: str,
123
+ workspace_root: AnyRoot
124
+ ) -> List[Dict]:
69
125
  """
70
126
  Load contents of a specific folder (for lazy loading).
71
127
 
72
128
  Args:
73
129
  folder_path: Relative path to the folder from workspace root
74
- workspace_root: Root workspace directory
130
+ workspace_root: Root workspace directory (Path or VirtualFilesystem)
75
131
 
76
132
  Returns:
77
133
  List of file/folder items in the specified folder
78
134
  """
79
- full_path = workspace_root / folder_path
135
+ full_path = _get_path(workspace_root, folder_path)
80
136
  return build_file_tree(full_path, workspace_root, lazy_load=True)
81
137
 
82
138
 
@@ -193,9 +249,13 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
193
249
  return components
194
250
 
195
251
 
196
- def read_file_content(workspace_root: Path, path: str) -> Tuple[str, bool, str]:
252
+ def read_file_content(
253
+ workspace_root: AnyRoot,
254
+ path: str
255
+ ) -> Tuple[Optional[str], bool, Optional[str]]:
197
256
  """Read file content. Returns (content, is_text, error)."""
198
- full_path = workspace_root / path
257
+ full_path = _get_path(workspace_root, path)
258
+
199
259
  if not full_path.exists() or not full_path.is_file():
200
260
  return None, False, "File not found"
201
261
 
@@ -211,9 +271,13 @@ def read_file_content(workspace_root: Path, path: str) -> Tuple[str, bool, str]:
211
271
  return None, False, "Binary file - download to view"
212
272
 
213
273
 
214
- def get_file_download_data(workspace_root: Path, path: str) -> Tuple[str, str, str]:
274
+ def get_file_download_data(
275
+ workspace_root: AnyRoot,
276
+ path: str
277
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
215
278
  """Get file data for download. Returns (base64_content, filename, mime_type)."""
216
- full_path = workspace_root / path
279
+ full_path = _get_path(workspace_root, path)
280
+
217
281
  if not full_path.exists():
218
282
  return None, None, None
219
283
 
@@ -222,7 +286,7 @@ def get_file_download_data(workspace_root: Path, path: str) -> Tuple[str, str, s
222
286
  b64 = base64.b64encode(content).decode('utf-8')
223
287
 
224
288
  # Determine MIME type
225
- ext = full_path.suffix.lower()
289
+ ext = PurePosixPath(path).suffix.lower()
226
290
  mime_types = {
227
291
  ".txt": "text/plain", ".py": "text/x-python", ".js": "text/javascript",
228
292
  ".json": "application/json", ".html": "text/html", ".css": "text/css",
@@ -232,6 +296,71 @@ def get_file_download_data(workspace_root: Path, path: str) -> Tuple[str, str, s
232
296
  }
233
297
  mime = mime_types.get(ext, "application/octet-stream")
234
298
 
235
- return b64, full_path.name, mime
299
+ # Get filename
300
+ if isinstance(full_path, VirtualPath):
301
+ filename = full_path.name
302
+ else:
303
+ filename = full_path.name
304
+
305
+ return b64, filename, mime
236
306
  except Exception:
237
307
  return None, None, None
308
+
309
+
310
+ def write_file(
311
+ workspace_root: AnyRoot,
312
+ path: str,
313
+ content: Union[str, bytes],
314
+ encoding: str = "utf-8"
315
+ ) -> bool:
316
+ """
317
+ Write content to a file.
318
+
319
+ Args:
320
+ workspace_root: Root workspace directory (Path or VirtualFilesystem)
321
+ path: Relative path to the file
322
+ content: Content to write (str or bytes)
323
+ encoding: Encoding for text content
324
+
325
+ Returns:
326
+ True if successful, False otherwise
327
+ """
328
+ full_path = _get_path(workspace_root, path)
329
+
330
+ try:
331
+ if isinstance(content, str):
332
+ full_path.write_text(content, encoding=encoding)
333
+ else:
334
+ full_path.write_bytes(content)
335
+ return True
336
+ except Exception as e:
337
+ print(f"Error writing file {path}: {e}")
338
+ return False
339
+
340
+
341
+ def create_directory(
342
+ workspace_root: AnyRoot,
343
+ path: str,
344
+ parents: bool = True,
345
+ exist_ok: bool = True
346
+ ) -> bool:
347
+ """
348
+ Create a directory.
349
+
350
+ Args:
351
+ workspace_root: Root workspace directory (Path or VirtualFilesystem)
352
+ path: Relative path to the directory
353
+ parents: Create parent directories if needed
354
+ exist_ok: Don't error if directory exists
355
+
356
+ Returns:
357
+ True if successful, False otherwise
358
+ """
359
+ full_path = _get_path(workspace_root, path)
360
+
361
+ try:
362
+ full_path.mkdir(parents=parents, exist_ok=exist_ok)
363
+ return True
364
+ except Exception as e:
365
+ print(f"Error creating directory {path}: {e}")
366
+ return False
cowork_dash/layout.py CHANGED
@@ -13,7 +13,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
13
13
  Create the app layout with current configuration.
14
14
 
15
15
  Args:
16
- workspace_root: Path to workspace directory
16
+ workspace_root: Path to workspace directory (or None for virtual FS mode)
17
17
  app_title: Application title
18
18
  app_subtitle: Application subtitle
19
19
  colors: Color scheme dictionary
@@ -27,6 +27,12 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
27
27
  # Use provided welcome message or fall back to default
28
28
  message = welcome_message if welcome_message is not None else DEFAULT_WELCOME_MESSAGE
29
29
 
30
+ # Build initial file tree (empty if workspace_root is None for virtual FS mode)
31
+ if workspace_root is not None:
32
+ initial_file_tree = render_file_tree(build_file_tree(workspace_root, workspace_root), colors, styles)
33
+ else:
34
+ initial_file_tree = [] # Empty tree for virtual FS - will be populated per-session
35
+
30
36
  return dmc.MantineProvider(
31
37
  id="mantine-provider",
32
38
  forceColorScheme="light",
@@ -39,6 +45,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
39
45
  dcc.Store(id="pending-message", data=None),
40
46
  dcc.Store(id="skip-history-render", data=False), # Flag to skip display_initial_messages render
41
47
  dcc.Store(id="session-initialized", data=False), # Flag to track if session has been initialized
48
+ dcc.Store(id="session-id", data=None, storage_type="session"), # Session ID for virtual FS isolation
42
49
  dcc.Store(id="expanded-folders", data=[]),
43
50
  dcc.Store(id="file-to-view", data=None),
44
51
  dcc.Store(id="file-click-tracker", data={}),
@@ -292,7 +299,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
292
299
  }),
293
300
  html.Div(
294
301
  id="file-tree",
295
- children=render_file_tree(build_file_tree(workspace_root, workspace_root), colors, styles),
302
+ children=initial_file_tree,
296
303
  style={
297
304
  "flex": "1",
298
305
  "overflowY": "auto",