cowork-dash 0.1.9__py3-none-any.whl → 0.2.0__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/cli.py CHANGED
@@ -148,6 +148,8 @@ cowork-dash run --help
148
148
 
149
149
  def run_app_cli(args):
150
150
  """Run the application with CLI arguments."""
151
+ import platform
152
+
151
153
  # Import here to avoid loading Dash when just running init
152
154
  from .app import run_app
153
155
 
@@ -155,6 +157,13 @@ def run_app_cli(args):
155
157
  # Otherwise pass None to let env var / config take precedence
156
158
  virtual_fs = True if args.virtual_fs else None
157
159
 
160
+ # Warn if --virtual-fs requested on non-Linux
161
+ if args.virtual_fs and platform.system() != "Linux":
162
+ print("⚠️ Warning: --virtual-fs is only supported on Linux")
163
+ print(" Virtual filesystem mode requires Linux for secure bash sandboxing (bubblewrap).")
164
+ print(" Running in physical filesystem mode instead.\n")
165
+ virtual_fs = None # Let config handle it (will be False)
166
+
158
167
  return run_app(
159
168
  workspace=args.workspace,
160
169
  agent_spec=args.agent,
cowork_dash/components.py CHANGED
@@ -73,18 +73,12 @@ def format_message(role: str, content: str, colors: Dict, styles: Dict, is_new:
73
73
 
74
74
 
75
75
  def format_loading(colors: Dict):
76
- """Format loading indicator."""
76
+ """Format loading indicator with dots loader."""
77
77
  return html.Div([
78
- html.Div([
79
- html.Span("Agent", className="message-role-agent", style={
80
- "fontSize": "12px", "fontWeight": "500",
81
- "textTransform": "uppercase",
82
- }),
83
- ], style={"marginBottom": "5px"}),
84
- html.Span("Thinking", className="loading-dots thinking-pulse thinking-text", style={
85
- "fontSize": "15px", "fontWeight": "500",
86
- })
87
- ], className="chat-message chat-message-loading", style={"padding": "12px 15px"})
78
+ dmc.Loader(type="dots", size="sm")
79
+ ], className="chat-message chat-message-loading", style={
80
+ "padding": "12px 15px",
81
+ })
88
82
 
89
83
 
90
84
  def format_thinking(thinking_text: str, colors: Dict):
@@ -97,7 +91,7 @@ def format_thinking(thinking_text: str, colors: Dict):
97
91
  html.Div(thinking_text, className="details-content details-content-thinking", style={
98
92
  "whiteSpace": "pre-wrap",
99
93
  })
100
- ], className="chat-details", style={
94
+ ], open=True, className="chat-details", style={
101
95
  "marginBottom": "4px",
102
96
  })
103
97
 
@@ -183,7 +177,7 @@ def format_todos_inline(todos, colors: Dict):
183
177
 
184
178
 
185
179
  def format_tool_call(tool_call: Dict, colors: Dict, is_completed: bool = False):
186
- """Format a single tool call as a submessage.
180
+ """Format a single tool call as a collapsible submessage.
187
181
 
188
182
  Args:
189
183
  tool_call: Dict with 'name', 'args', and optionally 'result', 'status'
@@ -195,23 +189,15 @@ def format_tool_call(tool_call: Dict, colors: Dict, is_completed: bool = False):
195
189
  tool_result = tool_call.get("result")
196
190
  tool_status = tool_call.get("status", "pending")
197
191
 
198
- # Status indicator - use CSS classes for theme awareness
192
+ # Status class for styling
199
193
  if tool_status == "success":
200
- status_icon = "✓"
201
194
  status_class = "tool-call-success"
202
- icon_class = "tool-call-icon-success"
203
195
  elif tool_status == "error":
204
- status_icon = "✗"
205
196
  status_class = "tool-call-error"
206
- icon_class = "tool-call-icon-error"
207
197
  elif tool_status == "running":
208
- status_icon = "◐"
209
198
  status_class = "tool-call-running"
210
- icon_class = "tool-call-icon-running"
211
199
  else: # pending
212
- status_icon = "○"
213
200
  status_class = "tool-call-pending"
214
- icon_class = "tool-call-icon-pending"
215
201
 
216
202
  # Format args for display (truncate if too long)
217
203
  args_display = ""
@@ -224,43 +210,363 @@ def format_tool_call(tool_call: Dict, colors: Dict, is_completed: bool = False):
224
210
  except:
225
211
  args_display = str(tool_args)[:500]
226
212
 
227
- # Build the tool call display using CSS classes
228
- tool_header = html.Div([
229
- html.Span(status_icon, className=icon_class, style={
230
- "marginRight": "10px",
231
- "fontWeight": "bold",
232
- }),
233
- html.Span("Tool: ", className="tool-call-label"),
234
- html.Span(tool_name, className="tool-call-name"),
235
- ], style={"display": "flex", "alignItems": "center"})
213
+ # Build inner content (shown when expanded)
214
+ inner_children = []
236
215
 
237
- # Arguments section (collapsible)
238
- args_section = None
216
+ # Arguments section
239
217
  if args_display:
240
- args_section = html.Details([
218
+ inner_children.append(html.Details([
241
219
  html.Summary("Arguments", className="tool-call-summary"),
242
220
  html.Pre(args_display, className="tool-call-args")
243
- ], style={"marginTop": "5px"})
221
+ ]))
244
222
 
245
- # Result section (collapsible, only if completed)
246
- result_section = None
223
+ # Result section (only if completed)
247
224
  if tool_result is not None and is_completed:
248
- result_display = str(tool_result)
249
- if len(result_display) > 500:
250
- result_display = result_display[:500] + "..."
225
+ # Special handling for display_inline results - render them richly
226
+ if tool_name == "display_inline" and isinstance(tool_result, dict) and tool_result.get("type") == "display_inline":
227
+ inner_children.append(render_display_inline_result(tool_result, colors))
228
+ else:
229
+ result_display = str(tool_result)
230
+ if len(result_display) > 500:
231
+ result_display = result_display[:500] + "..."
232
+
233
+ inner_children.append(html.Details([
234
+ html.Summary("Result", className="tool-call-summary"),
235
+ html.Pre(result_display, className="tool-call-result")
236
+ ]))
237
+
238
+ # Wrap entire tool call in a details element
239
+ return html.Details([
240
+ html.Summary([
241
+ html.Span(className="tool-call-status-dot"),
242
+ html.Span(tool_name, className="tool-call-name"),
243
+ ], className="tool-call-header"),
244
+ html.Div(inner_children, className="tool-call-body") if inner_children else None
245
+ ], className=f"tool-call {status_class}")
246
+
247
+
248
+ def render_display_inline_result(result: Dict, colors: Dict) -> html.Div:
249
+ """Render display_inline tool result as rich interactive content.
250
+
251
+ Args:
252
+ result: The display_inline result dictionary
253
+ colors: Color scheme dict
254
+
255
+ Returns:
256
+ A Dash html.Div component with the rendered content
257
+ """
258
+ display_type = result.get("display_type", "text")
259
+ title = result.get("title")
260
+ data = result.get("data")
261
+ preview = result.get("preview")
262
+ error = result.get("error")
263
+ status = result.get("status", "success")
264
+ filename = result.get("filename")
265
+ downloadable = result.get("downloadable", False)
266
+ csv_data = result.get("csv")
267
+
268
+ # Get item ID for potential debugging
269
+ item_id = result.get("_item_id", "unknown")
270
+
271
+ # Build title header if present
272
+ header_children = []
273
+ if title:
274
+ header_children.append(
275
+ html.Div(title, className="display-inline-title", style={
276
+ "fontWeight": "600",
277
+ "fontSize": "15px",
278
+ "marginBottom": "8px",
279
+ })
280
+ )
281
+
282
+ # Error state
283
+ if status == "error":
284
+ return html.Div([
285
+ *header_children,
286
+ html.Div([
287
+ html.Span("Error: ", style={"fontWeight": "600"}),
288
+ html.Span(error or "Unknown error")
289
+ ], className="display-inline-error", style={
290
+ "color": "#e53e3e",
291
+ "padding": "10px",
292
+ "borderRadius": "5px",
293
+ "backgroundColor": "rgba(229, 62, 62, 0.1)",
294
+ })
295
+ ], className="display-inline-container")
296
+
297
+ content_element = None
298
+
299
+ # Render based on display type
300
+ if display_type == "image":
301
+ mime_type = result.get("mime_type", "image/png")
302
+ data_url = f"data:{mime_type};base64,{data}"
303
+ content_element = html.Img(
304
+ src=data_url,
305
+ className="display-inline-image",
306
+ style={
307
+ "maxWidth": "100%",
308
+ "maxHeight": "400px",
309
+ "borderRadius": "5px",
310
+ "objectFit": "contain",
311
+ }
312
+ )
313
+
314
+ elif display_type == "plotly":
315
+ content_element = dcc.Graph(
316
+ figure=data,
317
+ config={"displayModeBar": True, "responsive": True},
318
+ style={"height": "350px"},
319
+ className="display-inline-plotly"
320
+ )
321
+
322
+ elif display_type == "dataframe":
323
+ # Show preview with expand option for full data
324
+ preview_html = preview.get("html", "") if isinstance(preview, dict) else ""
325
+ rows_shown = preview.get("rows_shown", 0) if isinstance(preview, dict) else 0
326
+ total_rows = preview.get("total_rows", 0) if isinstance(preview, dict) else 0
327
+ columns = preview.get("columns", []) if isinstance(preview, dict) else []
328
+
329
+ # Summary line
330
+ summary = f"{total_rows} rows × {len(columns)} columns"
331
+ if rows_shown < total_rows:
332
+ summary += f" (showing first {rows_shown})"
333
+
334
+ content_element = html.Div([
335
+ html.Div(summary, className="display-inline-df-summary", style={
336
+ "fontSize": "12px",
337
+ "marginBottom": "5px",
338
+ "opacity": "0.8",
339
+ }),
340
+ html.Div(
341
+ dcc.Markdown(preview_html, dangerously_allow_html=True),
342
+ className="display-inline-dataframe",
343
+ style={
344
+ "overflowX": "auto",
345
+ "maxHeight": "300px",
346
+ "overflowY": "auto",
347
+ }
348
+ )
349
+ ])
350
+
351
+ elif display_type == "html":
352
+ # Show preview thumbnail with expand button
353
+ preview_content = preview or data
354
+ if len(str(preview_content)) > 500:
355
+ preview_content = str(preview_content)[:500] + "..."
356
+
357
+ content_element = html.Details([
358
+ html.Summary("HTML Content", className="tool-call-summary"),
359
+ html.Iframe(
360
+ srcDoc=data,
361
+ style={
362
+ "width": "100%",
363
+ "height": "300px",
364
+ "border": "1px solid #ddd",
365
+ "borderRadius": "5px",
366
+ "backgroundColor": "white",
367
+ }
368
+ )
369
+ ], className="display-inline-html")
370
+
371
+ elif display_type == "pdf":
372
+ mime_type = result.get("mime_type", "application/pdf")
373
+ # Debug: Check if data exists
374
+ if not data:
375
+ content_element = html.Div(
376
+ "Error: PDF data is empty or missing",
377
+ style={"color": "red", "padding": "10px"}
378
+ )
379
+ else:
380
+ data_url = f"data:{mime_type};base64,{data}"
381
+ # Use iframe instead of embed for better browser compatibility
382
+ content_element = html.Iframe(
383
+ src=data_url,
384
+ style={
385
+ "width": "100%",
386
+ "height": "400px",
387
+ "border": "none",
388
+ "borderRadius": "5px",
389
+ }
390
+ )
251
391
 
252
- result_section = html.Details([
253
- html.Summary("Result", className="tool-call-summary"),
254
- html.Pre(result_display, className="tool-call-result")
255
- ], style={"marginTop": "5px"})
392
+ elif display_type == "json":
393
+ json_str = json.dumps(data, indent=2) if isinstance(data, (dict, list)) else str(data)
394
+ content_element = html.Details([
395
+ html.Summary("JSON Data", className="tool-call-summary"),
396
+ html.Pre(json_str, className="display-inline-json", style={
397
+ "maxHeight": "300px",
398
+ "overflowY": "auto",
399
+ "fontSize": "12px",
400
+ "padding": "10px",
401
+ "borderRadius": "5px",
402
+ })
403
+ ])
404
+
405
+ else: # text or unknown
406
+ text_content = str(data) if data else ""
407
+ if len(text_content) > 1000:
408
+ content_element = html.Details([
409
+ html.Summary(f"Text ({len(text_content)} chars)", className="tool-call-summary"),
410
+ html.Pre(text_content, className="display-inline-text", style={
411
+ "maxHeight": "300px",
412
+ "overflowY": "auto",
413
+ "whiteSpace": "pre-wrap",
414
+ "fontSize": "13px",
415
+ })
416
+ ])
417
+ else:
418
+ content_element = html.Pre(text_content, className="display-inline-text", style={
419
+ "whiteSpace": "pre-wrap",
420
+ "fontSize": "13px",
421
+ })
422
+
423
+ # Build footer with download option if applicable
424
+ footer_children = []
425
+ if filename:
426
+ footer_children.append(
427
+ html.Span(filename, className="display-inline-filename", style={
428
+ "fontSize": "11px",
429
+ "opacity": "0.7",
430
+ })
431
+ )
432
+
433
+ # Assemble final component with collapsible wrapper
434
+ # Note: Dash components evaluate to False in boolean context, so use 'is not None'
435
+
436
+ # Build the summary label for the collapsible header
437
+ display_type_labels = {
438
+ "image": "📷 Image",
439
+ "plotly": "📊 Chart",
440
+ "dataframe": "📋 Table",
441
+ "html": "🌐 HTML",
442
+ "pdf": "📄 PDF",
443
+ "json": "📝 JSON",
444
+ "text": "📝 Text",
445
+ "error": "⚠️ Error",
446
+ }
447
+ type_label = display_type_labels.get(display_type, "📎 Content")
448
+ summary_text = f"{type_label}: {title}" if title else type_label
449
+ if filename:
450
+ summary_text += f" ({filename})"
451
+
452
+ # Content to show inside the collapsible section
453
+ content_children = []
454
+ if content_element is not None:
455
+ content_children.append(content_element)
456
+ if footer_children:
457
+ content_children.append(
458
+ html.Div(footer_children, style={"marginTop": "5px"})
459
+ )
460
+
461
+ # Create "Add to Canvas" button with pattern-matching ID
462
+ # Using html.Button for reliable n_clicks behavior (DMC Tooltip can interfere with callbacks)
463
+ add_to_canvas_btn = html.Button(
464
+ DashIconify(icon="mdi:palette-outline", width=16),
465
+ id={"type": "add-display-to-canvas-btn", "index": item_id},
466
+ className="add-to-canvas-btn",
467
+ title="Add to Canvas",
468
+ style={
469
+ "background": "transparent",
470
+ "border": "none",
471
+ "cursor": "pointer",
472
+ "padding": "4px",
473
+ "borderRadius": "4px",
474
+ "display": "flex",
475
+ "alignItems": "center",
476
+ "justifyContent": "center",
477
+ }
478
+ )
479
+
480
+ # Store the result data in a hidden div for the callback to retrieve
481
+ result_store = dcc.Store(
482
+ id={"type": "display-inline-data", "index": item_id},
483
+ data=result
484
+ )
485
+
486
+ # Build summary with text and button
487
+ summary_content = html.Div([
488
+ html.Span(summary_text, className="display-inline-summary-text"),
489
+ add_to_canvas_btn,
490
+ ], className="display-inline-summary-row", style={
491
+ "display": "flex",
492
+ "alignItems": "center",
493
+ "justifyContent": "space-between",
494
+ "width": "100%",
495
+ })
496
+
497
+ return html.Details([
498
+ html.Summary(summary_content, className="display-inline-summary"),
499
+ html.Div(content_children, className="display-inline-content", style={
500
+ "marginTop": "8px",
501
+ }),
502
+ result_store, # Hidden store for callback data
503
+ ], className="display-inline-container", open=True, style={
504
+ "padding": "10px",
505
+ "borderRadius": "8px",
506
+ "marginTop": "5px",
507
+ })
508
+
509
+
510
+ def extract_display_inline_results(tool_calls: List[Dict], colors: Dict) -> List:
511
+ """Extract and render display_inline results prominently from tool calls.
512
+
513
+ This function finds all display_inline tool results and renders them
514
+ as standalone visual elements that appear alongside messages, rather
515
+ than buried in the tool call details.
516
+
517
+ Args:
518
+ tool_calls: List of tool call dicts with 'name', 'args', 'result', 'status'
519
+ colors: Color scheme dict
520
+
521
+ Returns:
522
+ List of rendered display_inline components (may be empty)
523
+ """
524
+ if not tool_calls:
525
+ return []
526
+
527
+ results = []
528
+ for tc in tool_calls:
529
+ if tc.get("name") == "display_inline" and tc.get("status") in ("success", "error"):
530
+ result = tc.get("result")
531
+ # Check if result is a dict with display_inline structure
532
+ if isinstance(result, dict) and result.get("type") == "display_inline":
533
+ rendered = render_display_inline_result(result, colors)
534
+ results.append(rendered)
256
535
 
257
- children = [tool_header]
258
- if args_section:
259
- children.append(args_section)
260
- if result_section:
261
- children.append(result_section)
536
+ return results
262
537
 
263
- return html.Div(children, className=f"tool-call {status_class}")
538
+
539
+ def extract_thinking_from_tool_calls(tool_calls: List[Dict], colors: Dict) -> List:
540
+ """Extract think_tool calls and render them as thinking blocks.
541
+
542
+ Args:
543
+ tool_calls: List of tool call dicts with 'name', 'args', 'result', 'status'
544
+ colors: Color scheme dict
545
+
546
+ Returns:
547
+ List of rendered thinking components (may be empty)
548
+ """
549
+ if not tool_calls:
550
+ return []
551
+
552
+ results = []
553
+ for tc in tool_calls:
554
+ if tc.get("name") == "think_tool" and tc.get("status") in ("success", "error"):
555
+ result = tc.get("result")
556
+ if result:
557
+ # Extract thinking text from result
558
+ thinking_text = ""
559
+ if isinstance(result, str):
560
+ thinking_text = result
561
+ elif isinstance(result, dict):
562
+ thinking_text = result.get("reflection", str(result))
563
+
564
+ if thinking_text:
565
+ thinking_block = format_thinking(thinking_text, colors)
566
+ if thinking_block:
567
+ results.append(thinking_block)
568
+
569
+ return results
264
570
 
265
571
 
266
572
  def format_tool_calls_inline(tool_calls: List[Dict], colors: Dict):
@@ -269,14 +575,23 @@ def format_tool_calls_inline(tool_calls: List[Dict], colors: Dict):
269
575
  Args:
270
576
  tool_calls: List of tool call dicts with 'name', 'args', 'result', 'status'
271
577
  colors: Color scheme dict
578
+
579
+ Note: think_tool calls are excluded from this rendering - they are rendered
580
+ separately via extract_thinking_from_tool_calls() to appear in correct order.
272
581
  """
273
582
  if not tool_calls:
274
583
  return None
275
584
 
276
- # Count statuses
277
- completed = sum(1 for tc in tool_calls if tc.get("status") in ("success", "error"))
278
- total = len(tool_calls)
279
- running = sum(1 for tc in tool_calls if tc.get("status") == "running")
585
+ # Filter out think_tool calls - they are rendered separately as thinking blocks
586
+ filtered_tool_calls = [tc for tc in tool_calls if tc.get("name") != "think_tool"]
587
+
588
+ if not filtered_tool_calls:
589
+ return None
590
+
591
+ # Count statuses (on filtered list)
592
+ completed = sum(1 for tc in filtered_tool_calls if tc.get("status") in ("success", "error"))
593
+ total = len(filtered_tool_calls)
594
+ running = sum(1 for tc in filtered_tool_calls if tc.get("status") == "running")
280
595
 
281
596
  # Summary text and class
282
597
  if running > 0:
@@ -291,7 +606,7 @@ def format_tool_calls_inline(tool_calls: List[Dict], colors: Dict):
291
606
 
292
607
  tool_elements = [
293
608
  format_tool_call(tc, colors, is_completed=tc.get("status") in ("success", "error"))
294
- for tc in tool_calls
609
+ for tc in filtered_tool_calls
295
610
  ]
296
611
 
297
612
  return html.Details([
@@ -435,6 +750,8 @@ def _get_type_badge(item_type: str) -> dmc.Badge:
435
750
  "image": "green",
436
751
  "plotly": "violet",
437
752
  "mermaid": "cyan",
753
+ "pdf": "red",
754
+ "error": "red",
438
755
  }
439
756
  type_labels = {
440
757
  "markdown": "Text",
@@ -443,6 +760,8 @@ def _get_type_badge(item_type: str) -> dmc.Badge:
443
760
  "image": "Image",
444
761
  "plotly": "Plot",
445
762
  "mermaid": "Diagram",
763
+ "pdf": "PDF",
764
+ "error": "Error",
446
765
  }
447
766
  color = type_colors.get(item_type, "gray")
448
767
  label = type_labels.get(item_type, item_type.title())
@@ -611,6 +930,30 @@ def render_canvas_items(canvas_items: List[Dict], colors: Dict, collapsed_ids: O
611
930
  "overflow": "auto",
612
931
  })
613
932
 
933
+ elif item_type == "pdf":
934
+ pdf_data = item.get("data", "")
935
+ mime_type = item.get("mime_type", "application/pdf")
936
+ if not pdf_data:
937
+ content = html.Div([
938
+ html.Div("Error: PDF data is empty or missing", style={"color": "red"})
939
+ ], className="canvas-item-content canvas-item-pdf", style={"padding": "10px"})
940
+ else:
941
+ data_url = f"data:{mime_type};base64,{pdf_data}"
942
+ # Use iframe instead of embed for better browser compatibility
943
+ content = html.Div([
944
+ html.Iframe(
945
+ src=data_url,
946
+ style={
947
+ "width": "100%",
948
+ "height": "500px",
949
+ "border": "none",
950
+ "borderRadius": "5px",
951
+ }
952
+ )
953
+ ], className="canvas-item-content canvas-item-pdf", style={
954
+ "padding": "10px",
955
+ })
956
+
614
957
  else:
615
958
  # Unknown type
616
959
  content = html.Div([
cowork_dash/config.py CHANGED
@@ -23,9 +23,15 @@ when no environment variables or CLI arguments are provided.
23
23
  """
24
24
 
25
25
  import os
26
+ import platform
26
27
  from pathlib import Path
27
28
 
28
29
 
30
+ def is_linux() -> bool:
31
+ """Check if running on Linux."""
32
+ return platform.system() == "Linux"
33
+
34
+
29
35
  def get_config(key: str, default=None, type_cast=None):
30
36
  """
31
37
  Get configuration value with priority:
@@ -111,18 +117,23 @@ WELCOME_MESSAGE = get_config("welcome_message", default=_default_welcome)
111
117
  # Virtual filesystem mode (for multi-user deployments)
112
118
  # Environment variable: DEEPAGENT_VIRTUAL_FS
113
119
  # Accepts: true/1/yes to enable
120
+ # IMPORTANT: Only available on Linux due to sandboxing requirements
114
121
  # When enabled:
115
122
  # - Each browser session gets isolated in-memory file storage
116
123
  # - Files, canvas, and uploads are not shared between sessions
117
124
  # - All data is ephemeral (cleared when session ends)
125
+ # - Bash commands run in bubblewrap sandbox for security
118
126
  # When disabled (default):
119
127
  # - All sessions share the same workspace directory on disk
120
128
  # - Files persist on disk
121
- VIRTUAL_FS = get_config(
129
+ _virtual_fs_requested = get_config(
122
130
  "virtual_fs",
123
131
  default=False,
124
132
  type_cast=lambda x: str(x).lower() in ("true", "1", "yes")
125
133
  )
134
+ # Virtual FS is only supported on Linux (requires bubblewrap for bash sandboxing)
135
+ VIRTUAL_FS = _virtual_fs_requested and is_linux()
136
+ VIRTUAL_FS_UNAVAILABLE_REASON = None if is_linux() else "Virtual filesystem mode requires Linux (uses bubblewrap for bash sandboxing)"
126
137
 
127
138
  # Session timeout in seconds (only used when VIRTUAL_FS is True)
128
139
  # Environment variable: DEEPAGENT_SESSION_TIMEOUT
cowork_dash/file_utils.py CHANGED
@@ -136,8 +136,18 @@ def load_folder_contents(
136
136
  return build_file_tree(full_path, workspace_root, lazy_load=True)
137
137
 
138
138
 
139
- def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int = 0, parent_path: str = "", expanded_folders: List[str] = None) -> List:
140
- """Render file tree with collapsible folders using CSS classes for theming."""
139
+ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int = 0, parent_path: str = "", expanded_folders: List[str] = None, workspace_root: AnyRoot = None) -> List:
140
+ """Render file tree with collapsible folders using CSS classes for theming.
141
+
142
+ Args:
143
+ items: List of file/folder items from build_file_tree
144
+ colors: Theme colors dict
145
+ styles: Style dict
146
+ level: Current nesting level
147
+ parent_path: Path of parent folder
148
+ expanded_folders: List of folder IDs that should be expanded
149
+ workspace_root: Workspace root for loading expanded folder contents
150
+ """
141
151
  components = []
142
152
  indent = level * 15 # Scaled up indent
143
153
  expanded_folders = expanded_folders or []
@@ -199,7 +209,7 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
199
209
 
200
210
  if children:
201
211
  # Children are loaded, render them
202
- child_content = render_file_tree(children, colors, styles, level + 1, item["path"], expanded_folders)
212
+ child_content = render_file_tree(children, colors, styles, level + 1, item["path"], expanded_folders, workspace_root)
203
213
  elif not has_children:
204
214
  # Folder is known to be empty
205
215
  child_content = [
@@ -210,8 +220,37 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
210
220
  "fontStyle": "italic",
211
221
  })
212
222
  ]
223
+ elif is_expanded and workspace_root is not None:
224
+ # Folder is expanded but children not loaded - load them now
225
+ # This happens when rebuilding the tree with preserved expanded_folders state
226
+ try:
227
+ folder_items = load_folder_contents(item["path"], workspace_root)
228
+ child_content = render_file_tree(folder_items, colors, styles, level + 1, item["path"], expanded_folders, workspace_root)
229
+ if not child_content:
230
+ child_content = [
231
+ html.Div("(empty)", className="file-tree-empty", style={
232
+ "padding": "4px 10px",
233
+ "paddingLeft": f"{25 + (level + 1) * 15}px",
234
+ "fontSize": "12px",
235
+ "fontStyle": "italic",
236
+ })
237
+ ]
238
+ except Exception:
239
+ # Fall back to loading placeholder if loading fails
240
+ child_content = [
241
+ html.Div("Loading...",
242
+ id={"type": "folder-loading", "path": folder_id},
243
+ className="file-tree-loading",
244
+ style={
245
+ "padding": "4px 10px",
246
+ "paddingLeft": f"{25 + (level + 1) * 15}px",
247
+ "fontSize": "12px",
248
+ "fontStyle": "italic",
249
+ }
250
+ )
251
+ ]
213
252
  else:
214
- # Children not yet loaded (lazy loading)
253
+ # Children not yet loaded (lazy loading) and folder is collapsed
215
254
  child_content = [
216
255
  html.Div("Loading...",
217
256
  id={"type": "folder-loading", "path": folder_id},