cowork-dash 0.1.8__py3-none-any.whl → 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cowork_dash/app.py CHANGED
@@ -17,7 +17,15 @@ 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
+ # Early pandas import to prevent circular import issues with Plotly's JSON serializer.
21
+ # Plotly lazily imports pandas and checks `obj is pd.NaT` which fails if pandas
22
+ # is partially initialized due to concurrent imports.
23
+ try:
24
+ import pandas
25
+ except (ImportError, AttributeError):
26
+ pass
27
+
28
+ from dash import Dash, html, dcc, Input, Output, State, callback_context, no_update, ALL
21
29
  from dash.exceptions import PreventUpdate
22
30
  import dash_mantine_components as dmc
23
31
  from dash_iconify import DashIconify
@@ -1493,7 +1501,8 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1493
1501
  @app.callback(
1494
1502
  [Output({"type": "folder-children", "path": ALL}, "style"),
1495
1503
  Output({"type": "folder-icon", "path": ALL}, "style"),
1496
- Output({"type": "folder-children", "path": ALL}, "children")],
1504
+ Output({"type": "folder-children", "path": ALL}, "children"),
1505
+ Output("expanded-folders", "data")],
1497
1506
  Input({"type": "folder-icon", "path": ALL}, "n_clicks"),
1498
1507
  [State({"type": "folder-header", "path": ALL}, "id"),
1499
1508
  State({"type": "folder-header", "path": ALL}, "data-realpath"),
@@ -1503,16 +1512,18 @@ def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_
1503
1512
  State({"type": "folder-icon", "path": ALL}, "style"),
1504
1513
  State({"type": "folder-children", "path": ALL}, "children"),
1505
1514
  State("theme-store", "data"),
1506
- State("session-id", "data")],
1515
+ State("session-id", "data"),
1516
+ State("expanded-folders", "data")],
1507
1517
  prevent_initial_call=True
1508
1518
  )
1509
- def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme, session_id):
1519
+ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme, session_id, expanded_folders):
1510
1520
  """Toggle folder expansion and lazy load contents if needed."""
1511
1521
  ctx = callback_context
1512
1522
  if not ctx.triggered or not any(n_clicks):
1513
1523
  raise PreventUpdate
1514
1524
 
1515
1525
  colors = get_colors(theme or "light")
1526
+ expanded_folders = expanded_folders or []
1516
1527
 
1517
1528
  # Get workspace for this session (virtual or physical)
1518
1529
  workspace_root = get_workspace_for_session(session_id)
@@ -1538,6 +1549,9 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1538
1549
  new_icon_styles = []
1539
1550
  new_children_content = []
1540
1551
 
1552
+ # Track whether we're expanding or collapsing the clicked folder
1553
+ will_expand = None
1554
+
1541
1555
  # Process all folder-children elements
1542
1556
  for i, child_id in enumerate(children_ids):
1543
1557
  path = child_id["path"]
@@ -1547,6 +1561,7 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1547
1561
  if path == clicked_path:
1548
1562
  # Toggle this folder
1549
1563
  is_expanded = current_style.get("display") != "none"
1564
+ will_expand = not is_expanded
1550
1565
  new_children_styles.append({"display": "none" if is_expanded else "block"})
1551
1566
 
1552
1567
  # If expanding and content is just "Loading...", load the actual contents
@@ -1560,7 +1575,8 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1560
1575
  folder_items = load_folder_contents(folder_rel_path, workspace_root)
1561
1576
  loaded_content = render_file_tree(folder_items, colors, STYLES,
1562
1577
  level=folder_rel_path.count("/") + folder_rel_path.count("\\") + 1,
1563
- parent_path=folder_rel_path)
1578
+ parent_path=folder_rel_path,
1579
+ expanded_folders=expanded_folders)
1564
1580
  new_children_content.append(loaded_content if loaded_content else current_content)
1565
1581
  except Exception as e:
1566
1582
  print(f"Error loading folder {folder_rel_path}: {e}")
@@ -1597,14 +1613,23 @@ def toggle_folder(n_clicks, header_ids, real_paths, children_ids, icon_ids, chil
1597
1613
  else:
1598
1614
  new_icon_styles.append(current_icon_style)
1599
1615
 
1600
- return new_children_styles, new_icon_styles, new_children_content
1616
+ # Update expanded folders list
1617
+ new_expanded_folders = list(expanded_folders)
1618
+ if will_expand is not None:
1619
+ if will_expand and clicked_path not in new_expanded_folders:
1620
+ new_expanded_folders.append(clicked_path)
1621
+ elif not will_expand and clicked_path in new_expanded_folders:
1622
+ new_expanded_folders.remove(clicked_path)
1623
+
1624
+ return new_children_styles, new_icon_styles, new_children_content, new_expanded_folders
1601
1625
 
1602
1626
 
1603
1627
  # Enter folder callback - triggered by double-clicking folder name (changes workspace root)
1604
1628
  @app.callback(
1605
1629
  [Output("current-workspace-path", "data"),
1606
1630
  Output("workspace-breadcrumb", "children"),
1607
- Output("file-tree", "children", allow_duplicate=True)],
1631
+ Output("file-tree", "children", allow_duplicate=True),
1632
+ Output("expanded-folders", "data", allow_duplicate=True)],
1608
1633
  [Input({"type": "folder-select", "path": ALL}, "n_clicks"),
1609
1634
  Input("breadcrumb-root", "n_clicks"),
1610
1635
  Input({"type": "breadcrumb-segment", "index": ALL}, "n_clicks")],
@@ -1720,13 +1745,13 @@ def enter_folder(folder_clicks, root_clicks, breadcrumb_clicks, folder_ids, fold
1720
1745
  else:
1721
1746
  workspace_full_path = workspace_root / new_path if new_path else workspace_root
1722
1747
 
1723
- # Render new file tree
1748
+ # Render new file tree (reset expanded folders when navigating)
1724
1749
  file_tree = render_file_tree(
1725
1750
  build_file_tree(workspace_full_path, workspace_full_path),
1726
1751
  colors, STYLES
1727
1752
  )
1728
1753
 
1729
- return new_path, breadcrumb_children, file_tree
1754
+ return new_path, breadcrumb_children, file_tree, [] # Reset expanded folders
1730
1755
 
1731
1756
 
1732
1757
  # File click - open modal
@@ -1805,23 +1830,207 @@ def open_file_modal(all_n_clicks, all_ids, click_tracker, theme, session_id):
1805
1830
  colors = get_colors(theme or "light")
1806
1831
  content, is_text, error = read_file_content(workspace_root, file_path)
1807
1832
  filename = Path(file_path).name
1808
-
1809
- if is_text and content:
1810
- modal_content = html.Pre(
1811
- content,
1812
- style={
1813
- "background": colors["bg_tertiary"],
1814
- "padding": "16px",
1815
- "fontSize": "12px",
1816
- "fontFamily": "'IBM Plex Mono', monospace",
1817
- "overflow": "auto",
1818
- "maxHeight": "60vh",
1819
- "whiteSpace": "pre-wrap",
1820
- "wordBreak": "break-word",
1821
- "margin": "0",
1822
- "color": colors["text_primary"],
1823
- }
1824
- )
1833
+ file_ext = Path(file_path).suffix.lower()
1834
+
1835
+ # Define file type categories for binary previews
1836
+ image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp'}
1837
+ pdf_exts = {'.pdf'}
1838
+
1839
+ # Check for binary preview types first
1840
+ if file_ext in image_exts | pdf_exts:
1841
+ b64, _, mime = get_file_download_data(workspace_root, file_path)
1842
+ if b64:
1843
+ data_url = f"data:{mime};base64,{b64}"
1844
+
1845
+ if file_ext in image_exts:
1846
+ # Image preview
1847
+ modal_content = html.Div([
1848
+ html.Img(
1849
+ src=data_url,
1850
+ style={
1851
+ "maxWidth": "100%",
1852
+ "maxHeight": "80vh",
1853
+ "display": "block",
1854
+ "margin": "0 auto",
1855
+ "borderRadius": "4px",
1856
+ }
1857
+ )
1858
+ ], style={"textAlign": "center"})
1859
+
1860
+ elif file_ext in pdf_exts:
1861
+ # PDF preview via embed
1862
+ modal_content = html.Embed(
1863
+ src=data_url,
1864
+ type="application/pdf",
1865
+ style={
1866
+ "width": "100%",
1867
+ "height": "80vh",
1868
+ "borderRadius": "4px",
1869
+ }
1870
+ )
1871
+ else:
1872
+ # Failed to read binary file
1873
+ modal_content = html.Div([
1874
+ html.P("Failed to load file preview", style={
1875
+ "color": colors["text_muted"],
1876
+ "textAlign": "center",
1877
+ "padding": "40px",
1878
+ }),
1879
+ html.P("Click Download to save the file.", style={
1880
+ "color": colors["text_muted"],
1881
+ "textAlign": "center",
1882
+ "fontSize": "13px",
1883
+ })
1884
+ ])
1885
+
1886
+ elif is_text and content:
1887
+ # HTML files get rendered preview
1888
+ if file_ext in ('.html', '.htm'):
1889
+ modal_content = html.Div([
1890
+ # Tab buttons for switching views
1891
+ html.Div([
1892
+ html.Button("Preview", id="html-preview-tab", n_clicks=0,
1893
+ className="html-tab-btn html-tab-active",
1894
+ style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
1895
+ "borderRadius": "4px", "cursor": "pointer",
1896
+ "background": colors["accent"], "color": "#fff"}),
1897
+ html.Button("Source", id="html-source-tab", n_clicks=0,
1898
+ className="html-tab-btn",
1899
+ style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
1900
+ "borderRadius": "4px", "cursor": "pointer",
1901
+ "background": "transparent", "color": colors["text_primary"]}),
1902
+ ], style={"marginBottom": "12px", "display": "flex"}),
1903
+ # Preview iframe (default visible)
1904
+ html.Iframe(
1905
+ srcDoc=content,
1906
+ style={
1907
+ "width": "100%",
1908
+ "height": "80vh",
1909
+ "border": f"1px solid {colors['border']}",
1910
+ "borderRadius": "4px",
1911
+ "background": "#fff",
1912
+ },
1913
+ id="html-preview-frame"
1914
+ ),
1915
+ # Source code (hidden by default)
1916
+ html.Pre(
1917
+ content,
1918
+ id="html-source-code",
1919
+ style={
1920
+ "display": "none",
1921
+ "background": colors["bg_tertiary"],
1922
+ "padding": "16px",
1923
+ "fontSize": "12px",
1924
+ "fontFamily": "'IBM Plex Mono', monospace",
1925
+ "overflow": "auto",
1926
+ "maxHeight": "80vh",
1927
+ "whiteSpace": "pre-wrap",
1928
+ "wordBreak": "break-word",
1929
+ "margin": "0",
1930
+ "color": colors["text_primary"],
1931
+ "border": f"1px solid {colors['border']}",
1932
+ "borderRadius": "4px",
1933
+ }
1934
+ )
1935
+ ])
1936
+ elif file_ext == '.json':
1937
+ # Try to parse as Plotly JSON figure
1938
+ plotly_figure = None
1939
+ try:
1940
+ data = json.loads(content)
1941
+ # Check if it looks like a Plotly figure (has 'data' key with list)
1942
+ if isinstance(data, dict) and 'data' in data and isinstance(data['data'], list):
1943
+ plotly_figure = data
1944
+ except (json.JSONDecodeError, KeyError):
1945
+ pass
1946
+
1947
+ if plotly_figure:
1948
+ # Render as interactive Plotly chart with source toggle
1949
+ modal_content = html.Div([
1950
+ # Tab buttons for switching views
1951
+ html.Div([
1952
+ html.Button("Chart", id="html-preview-tab", n_clicks=0,
1953
+ className="html-tab-btn html-tab-active",
1954
+ style={"marginRight": "8px", "padding": "6px 12px", "border": "none",
1955
+ "borderRadius": "4px", "cursor": "pointer",
1956
+ "background": colors["accent"], "color": "#fff"}),
1957
+ html.Button("JSON", id="html-source-tab", n_clicks=0,
1958
+ className="html-tab-btn",
1959
+ style={"padding": "6px 12px", "border": f"1px solid {colors['border']}",
1960
+ "borderRadius": "4px", "cursor": "pointer",
1961
+ "background": "transparent", "color": colors["text_primary"]}),
1962
+ ], style={"marginBottom": "12px", "display": "flex"}),
1963
+ # Plotly chart (default visible)
1964
+ html.Div([
1965
+ dcc.Graph(
1966
+ figure=plotly_figure,
1967
+ style={"height": "75vh"},
1968
+ config={"displayModeBar": True, "responsive": True}
1969
+ )
1970
+ ], id="html-preview-frame", style={
1971
+ "border": f"1px solid {colors['border']}",
1972
+ "borderRadius": "4px",
1973
+ "background": "#fff",
1974
+ }),
1975
+ # JSON source (hidden by default)
1976
+ html.Pre(
1977
+ json.dumps(plotly_figure, indent=2),
1978
+ id="html-source-code",
1979
+ style={
1980
+ "display": "none",
1981
+ "background": colors["bg_tertiary"],
1982
+ "padding": "16px",
1983
+ "fontSize": "12px",
1984
+ "fontFamily": "'IBM Plex Mono', monospace",
1985
+ "overflow": "auto",
1986
+ "maxHeight": "80vh",
1987
+ "whiteSpace": "pre-wrap",
1988
+ "wordBreak": "break-word",
1989
+ "margin": "0",
1990
+ "color": colors["text_primary"],
1991
+ "border": f"1px solid {colors['border']}",
1992
+ "borderRadius": "4px",
1993
+ }
1994
+ )
1995
+ ])
1996
+ else:
1997
+ # Regular JSON - show formatted
1998
+ try:
1999
+ formatted = json.dumps(json.loads(content), indent=2)
2000
+ except json.JSONDecodeError:
2001
+ formatted = content
2002
+ modal_content = html.Pre(
2003
+ formatted,
2004
+ style={
2005
+ "background": colors["bg_tertiary"],
2006
+ "padding": "16px",
2007
+ "fontSize": "12px",
2008
+ "fontFamily": "'IBM Plex Mono', monospace",
2009
+ "overflow": "auto",
2010
+ "maxHeight": "80vh",
2011
+ "whiteSpace": "pre-wrap",
2012
+ "wordBreak": "break-word",
2013
+ "margin": "0",
2014
+ "color": colors["text_primary"],
2015
+ }
2016
+ )
2017
+ else:
2018
+ # Regular text files
2019
+ modal_content = html.Pre(
2020
+ content,
2021
+ style={
2022
+ "background": colors["bg_tertiary"],
2023
+ "padding": "16px",
2024
+ "fontSize": "12px",
2025
+ "fontFamily": "'IBM Plex Mono', monospace",
2026
+ "overflow": "auto",
2027
+ "maxHeight": "80vh",
2028
+ "whiteSpace": "pre-wrap",
2029
+ "wordBreak": "break-word",
2030
+ "margin": "0",
2031
+ "color": colors["text_primary"],
2032
+ }
2033
+ )
1825
2034
  else:
1826
2035
  modal_content = html.Div([
1827
2036
  html.P(error or "Cannot display file", style={
@@ -1870,6 +2079,71 @@ def download_from_modal(n_clicks, file_path, session_id):
1870
2079
  return dict(content=b64, filename=filename, base64=True, type=mime)
1871
2080
 
1872
2081
 
2082
+ # HTML preview/source tab switching
2083
+ @app.callback(
2084
+ [Output("html-preview-frame", "style"),
2085
+ Output("html-source-code", "style"),
2086
+ Output("html-preview-tab", "style"),
2087
+ Output("html-source-tab", "style")],
2088
+ [Input("html-preview-tab", "n_clicks"),
2089
+ Input("html-source-tab", "n_clicks")],
2090
+ [State("theme-store", "data")],
2091
+ prevent_initial_call=True
2092
+ )
2093
+ def toggle_html_view(preview_clicks, source_clicks, theme):
2094
+ """Toggle between HTML preview and source code view."""
2095
+ ctx = callback_context
2096
+ if not ctx.triggered:
2097
+ raise PreventUpdate
2098
+
2099
+ colors = get_colors(theme or "light")
2100
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
2101
+
2102
+ # Base styles
2103
+ preview_frame_style = {
2104
+ "width": "100%",
2105
+ "height": "80vh",
2106
+ "border": f"1px solid {colors['border']}",
2107
+ "borderRadius": "4px",
2108
+ "background": "#fff",
2109
+ }
2110
+ source_code_style = {
2111
+ "background": colors["bg_tertiary"],
2112
+ "padding": "16px",
2113
+ "fontSize": "12px",
2114
+ "fontFamily": "'IBM Plex Mono', monospace",
2115
+ "overflow": "auto",
2116
+ "maxHeight": "80vh",
2117
+ "whiteSpace": "pre-wrap",
2118
+ "wordBreak": "break-word",
2119
+ "margin": "0",
2120
+ "color": colors["text_primary"],
2121
+ "border": f"1px solid {colors['border']}",
2122
+ "borderRadius": "4px",
2123
+ }
2124
+ active_btn_style = {
2125
+ "marginRight": "8px", "padding": "6px 12px", "border": "none",
2126
+ "borderRadius": "4px", "cursor": "pointer",
2127
+ "background": colors["accent"], "color": "#fff"
2128
+ }
2129
+ inactive_btn_style = {
2130
+ "padding": "6px 12px", "border": f"1px solid {colors['border']}",
2131
+ "borderRadius": "4px", "cursor": "pointer",
2132
+ "background": "transparent", "color": colors["text_primary"]
2133
+ }
2134
+
2135
+ if triggered_id == "html-source-tab":
2136
+ # Show source, hide preview
2137
+ preview_frame_style["display"] = "none"
2138
+ source_code_style["display"] = "block"
2139
+ return preview_frame_style, source_code_style, {**inactive_btn_style, "marginRight": "8px"}, active_btn_style
2140
+ else:
2141
+ # Show preview, hide source (default)
2142
+ preview_frame_style["display"] = "block"
2143
+ source_code_style["display"] = "none"
2144
+ return preview_frame_style, source_code_style, active_btn_style, {**inactive_btn_style}
2145
+
2146
+
1873
2147
  # Open terminal
1874
2148
  @app.callback(
1875
2149
  Output("open-terminal-btn", "n_clicks"),
@@ -1922,13 +2196,15 @@ def open_terminal(n_clicks):
1922
2196
  [State("current-workspace-path", "data"),
1923
2197
  State("theme-store", "data"),
1924
2198
  State("collapsed-canvas-items", "data"),
1925
- State("session-id", "data")],
2199
+ State("session-id", "data"),
2200
+ State("expanded-folders", "data")],
1926
2201
  prevent_initial_call=True
1927
2202
  )
1928
- def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id):
2203
+ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_id, expanded_folders):
1929
2204
  """Refresh both file tree and canvas content."""
1930
2205
  colors = get_colors(theme or "light")
1931
2206
  collapsed_ids = collapsed_ids or []
2207
+ expanded_folders = expanded_folders or []
1932
2208
 
1933
2209
  # Get workspace for this session (virtual or physical)
1934
2210
  workspace_root = get_workspace_for_session(session_id)
@@ -1939,8 +2215,8 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_i
1939
2215
  else:
1940
2216
  current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
1941
2217
 
1942
- # Refresh file tree for current workspace
1943
- file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
2218
+ # Refresh file tree for current workspace, preserving expanded folders
2219
+ file_tree = render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders)
1944
2220
 
1945
2221
  # Re-render canvas from current in-memory state (don't reload from file)
1946
2222
  # This preserves canvas items that may not have been exported to .canvas/canvas.md yet
@@ -1960,15 +2236,17 @@ def refresh_sidebar(n_clicks, current_workspace, theme, collapsed_ids, session_i
1960
2236
  [State("file-upload-sidebar", "filename"),
1961
2237
  State("current-workspace-path", "data"),
1962
2238
  State("theme-store", "data"),
1963
- State("session-id", "data")],
2239
+ State("session-id", "data"),
2240
+ State("expanded-folders", "data")],
1964
2241
  prevent_initial_call=True
1965
2242
  )
1966
- def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id):
2243
+ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session_id, expanded_folders):
1967
2244
  """Handle file uploads from sidebar button to current workspace."""
1968
2245
  if not contents:
1969
2246
  raise PreventUpdate
1970
2247
 
1971
2248
  colors = get_colors(theme or "light")
2249
+ expanded_folders = expanded_folders or []
1972
2250
 
1973
2251
  # Get workspace for this session (virtual or physical)
1974
2252
  workspace_root = get_workspace_for_session(session_id)
@@ -1991,7 +2269,7 @@ def handle_sidebar_upload(contents, filenames, current_workspace, theme, session
1991
2269
  except Exception as e:
1992
2270
  print(f"Upload error: {e}")
1993
2271
 
1994
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
2272
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders)
1995
2273
 
1996
2274
 
1997
2275
  # Create folder modal - open
@@ -2034,15 +2312,17 @@ def toggle_create_folder_modal(open_clicks, cancel_clicks, confirm_clicks, is_op
2034
2312
  [State("new-folder-name", "value"),
2035
2313
  State("current-workspace-path", "data"),
2036
2314
  State("theme-store", "data"),
2037
- State("session-id", "data")],
2315
+ State("session-id", "data"),
2316
+ State("expanded-folders", "data")],
2038
2317
  prevent_initial_call=True
2039
2318
  )
2040
- def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
2319
+ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id, expanded_folders):
2041
2320
  """Create a new folder in the current workspace directory."""
2042
2321
  if not n_clicks:
2043
2322
  raise PreventUpdate
2044
2323
 
2045
2324
  colors = get_colors(theme or "light")
2325
+ expanded_folders = expanded_folders or []
2046
2326
 
2047
2327
  if not folder_name or not folder_name.strip():
2048
2328
  return no_update, "Please enter a folder name", no_update
@@ -2070,7 +2350,7 @@ def create_folder(n_clicks, folder_name, current_workspace, theme, session_id):
2070
2350
 
2071
2351
  try:
2072
2352
  folder_path.mkdir(parents=True, exist_ok=False)
2073
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES), "", ""
2353
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders), "", ""
2074
2354
  except Exception as e:
2075
2355
  return no_update, f"Error creating folder: {e}", no_update
2076
2356
 
@@ -2156,15 +2436,17 @@ def update_canvas_content(n_intervals, view_value, theme, collapsed_ids, session
2156
2436
  [State("current-workspace-path", "data"),
2157
2437
  State("theme-store", "data"),
2158
2438
  State("session-id", "data"),
2159
- State("sidebar-view-toggle", "value")],
2439
+ State("sidebar-view-toggle", "value"),
2440
+ State("expanded-folders", "data")],
2160
2441
  prevent_initial_call=True
2161
2442
  )
2162
- def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, view_value):
2443
+ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, view_value, expanded_folders):
2163
2444
  """Refresh file tree during agent execution to show newly created files.
2164
2445
 
2165
2446
  This callback runs on each poll interval and refreshes the file tree
2166
2447
  so that files created by the agent are visible in real-time.
2167
2448
  Only updates when viewing files (not canvas).
2449
+ Preserves expanded folder state across refreshes.
2168
2450
  """
2169
2451
  # Only refresh when viewing files panel
2170
2452
  if view_value != "files":
@@ -2180,6 +2462,7 @@ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, vie
2180
2462
  raise PreventUpdate
2181
2463
 
2182
2464
  colors = get_colors(theme or "light")
2465
+ expanded_folders = expanded_folders or []
2183
2466
 
2184
2467
  # Get workspace for this session (virtual or physical)
2185
2468
  workspace_root = get_workspace_for_session(session_id)
@@ -2190,8 +2473,8 @@ def poll_file_tree_update(n_intervals, current_workspace, theme, session_id, vie
2190
2473
  else:
2191
2474
  current_workspace_dir = workspace_root / current_workspace if current_workspace else workspace_root
2192
2475
 
2193
- # Refresh file tree
2194
- return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES)
2476
+ # Refresh file tree, preserving expanded folder state
2477
+ return render_file_tree(build_file_tree(current_workspace_dir, current_workspace_dir), colors, STYLES, expanded_folders=expanded_folders)
2195
2478
 
2196
2479
 
2197
2480
  # Open clear canvas confirmation modal
cowork_dash/canvas.py CHANGED
@@ -13,6 +13,14 @@ from pathlib import Path
13
13
  from typing import Any, Dict, List, Optional, Union
14
14
  from datetime import datetime
15
15
 
16
+ # Early pandas import to prevent circular import issues with Plotly's JSON serializer.
17
+ # Plotly lazily imports pandas and checks `obj is pd.NaT` which fails if pandas
18
+ # is partially initialized due to concurrent imports.
19
+ try:
20
+ import pandas
21
+ except (ImportError, AttributeError):
22
+ pass
23
+
16
24
  from .virtual_fs import VirtualFilesystem, VirtualPath
17
25
 
18
26
 
cowork_dash/file_utils.py CHANGED
@@ -136,15 +136,17 @@ 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 = "") -> List:
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
140
  """Render file tree with collapsible folders using CSS classes for theming."""
141
141
  components = []
142
142
  indent = level * 15 # Scaled up indent
143
+ expanded_folders = expanded_folders or []
143
144
 
144
145
  for item in items:
145
146
  if item["type"] == "folder":
146
147
  folder_id = item["path"].replace("/", "_").replace("\\", "_")
147
148
  children = item.get("children", [])
149
+ is_expanded = folder_id in expanded_folders
148
150
 
149
151
  # Folder header with expand icon and selectable name
150
152
  components.append(
@@ -160,6 +162,7 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
160
162
  "transition": "transform 0.15s",
161
163
  "display": "inline-block",
162
164
  "padding": "2px",
165
+ "transform": "rotate(90deg)" if is_expanded else "rotate(0deg)",
163
166
  }
164
167
  ),
165
168
  # Folder name (clickable for selection)
@@ -196,7 +199,7 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
196
199
 
197
200
  if children:
198
201
  # Children are loaded, render them
199
- child_content = render_file_tree(children, colors, styles, level + 1, item["path"])
202
+ child_content = render_file_tree(children, colors, styles, level + 1, item["path"], expanded_folders)
200
203
  elif not has_children:
201
204
  # Folder is known to be empty
202
205
  child_content = [
@@ -226,7 +229,7 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
226
229
  html.Div(
227
230
  child_content,
228
231
  id={"type": "folder-children", "path": folder_id},
229
- style={"display": "none"} # Collapsed by default
232
+ style={"display": "block" if is_expanded else "none"}
230
233
  )
231
234
  )
232
235
  else:
@@ -288,11 +291,25 @@ def get_file_download_data(
288
291
  # Determine MIME type
289
292
  ext = PurePosixPath(path).suffix.lower()
290
293
  mime_types = {
294
+ # Text
291
295
  ".txt": "text/plain", ".py": "text/x-python", ".js": "text/javascript",
292
- ".json": "application/json", ".html": "text/html", ".css": "text/css",
293
- ".md": "text/markdown", ".csv": "text/csv", ".xml": "text/xml",
294
- ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg",
295
- ".gif": "image/gif", ".zip": "application/zip",
296
+ ".json": "application/json", ".html": "text/html", ".htm": "text/html",
297
+ ".css": "text/css", ".md": "text/markdown", ".csv": "text/csv",
298
+ ".xml": "text/xml", ".yaml": "text/yaml", ".yml": "text/yaml",
299
+ # Images
300
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
301
+ ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
302
+ ".ico": "image/x-icon", ".bmp": "image/bmp",
303
+ # Video
304
+ ".mp4": "video/mp4", ".webm": "video/webm", ".ogg": "video/ogg",
305
+ ".mov": "video/quicktime",
306
+ # Audio
307
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
308
+ ".flac": "audio/flac",
309
+ # Documents
310
+ ".pdf": "application/pdf",
311
+ # Archives
312
+ ".zip": "application/zip",
296
313
  }
297
314
  mime = mime_types.get(ext, "application/octet-stream")
298
315
 
cowork_dash/tools.py CHANGED
@@ -1,15 +1,61 @@
1
1
  from typing import Any, Dict, List, Optional
2
2
  import sys
3
3
  import io
4
+ import os
4
5
  import traceback
5
6
  import subprocess
6
7
  import threading
7
- from contextlib import redirect_stdout, redirect_stderr
8
+ import platform
9
+ from contextlib import redirect_stdout, redirect_stderr, contextmanager
8
10
 
9
11
  from .config import WORKSPACE_ROOT, VIRTUAL_FS
10
12
  from .canvas import parse_canvas_object, generate_canvas_id
11
13
 
12
14
 
15
+ # Memory limit for cell execution (in bytes)
16
+ # Default: 512 MB - can be overridden via environment variable
17
+ CELL_MEMORY_LIMIT_MB = int(os.environ.get("COWORK_CELL_MEMORY_LIMIT_MB", "512"))
18
+ CELL_MEMORY_LIMIT_BYTES = CELL_MEMORY_LIMIT_MB * 1024 * 1024
19
+
20
+
21
+ @contextmanager
22
+ def memory_limit(max_bytes: int = CELL_MEMORY_LIMIT_BYTES):
23
+ """Context manager to set memory limits for code execution on Linux.
24
+
25
+ On Linux, uses resource.setrlimit() to set soft memory limit.
26
+ On other platforms (macOS, Windows), this is a no-op as they don't
27
+ support RLIMIT_AS in the same way or at all.
28
+
29
+ The limit applies to the virtual address space (RLIMIT_AS) which
30
+ will cause MemoryError when exceeded rather than OOM kill.
31
+
32
+ Args:
33
+ max_bytes: Maximum memory in bytes. Default from CELL_MEMORY_LIMIT_BYTES.
34
+ """
35
+ if platform.system() != "Linux":
36
+ # Memory limits via resource module only work reliably on Linux
37
+ yield
38
+ return
39
+
40
+ try:
41
+ import resource
42
+ except ImportError:
43
+ yield
44
+ return
45
+
46
+ # Get current limits
47
+ soft, hard = resource.getrlimit(resource.RLIMIT_AS)
48
+
49
+ try:
50
+ # Set new soft limit (don't exceed hard limit)
51
+ new_soft = min(max_bytes, hard) if hard != resource.RLIM_INFINITY else max_bytes
52
+ resource.setrlimit(resource.RLIMIT_AS, (new_soft, hard))
53
+ yield
54
+ finally:
55
+ # Restore original limits
56
+ resource.setrlimit(resource.RLIMIT_AS, (soft, hard))
57
+
58
+
13
59
  # Thread-local storage for current session context
14
60
  # This allows tools to know which session they're operating in
15
61
  _tool_context = threading.local()
@@ -383,47 +429,54 @@ except (ImportError, AttributeError):
383
429
  }
384
430
 
385
431
  try:
386
- # Try IPython first for better execution handling
387
- ipython = self._get_ipython()
388
-
389
- if ipython is not None:
390
- # Use IPython's run_cell for magic commands support
391
- with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
392
- exec_result = ipython.run_cell(cell["source"], store_history=True)
393
-
394
- result["stdout"] = stdout_capture.getvalue()
395
- result["stderr"] = stderr_capture.getvalue()
396
-
397
- if exec_result.success:
398
- if exec_result.result is not None:
399
- result["result"] = repr(exec_result.result)
432
+ # Apply memory limit on Linux to prevent OOM kills
433
+ with memory_limit():
434
+ # Try IPython first for better execution handling
435
+ ipython = self._get_ipython()
436
+
437
+ if ipython is not None:
438
+ # Use IPython's run_cell for magic commands support
439
+ with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
440
+ exec_result = ipython.run_cell(cell["source"], store_history=True)
441
+
442
+ result["stdout"] = stdout_capture.getvalue()
443
+ result["stderr"] = stderr_capture.getvalue()
444
+
445
+ if exec_result.success:
446
+ if exec_result.result is not None:
447
+ result["result"] = repr(exec_result.result)
448
+ else:
449
+ if exec_result.error_in_exec:
450
+ result["error"] = str(exec_result.error_in_exec)
451
+ result["status"] = "error"
452
+ elif exec_result.error_before_exec:
453
+ result["error"] = str(exec_result.error_before_exec)
454
+ result["status"] = "error"
400
455
  else:
401
- if exec_result.error_in_exec:
402
- result["error"] = str(exec_result.error_in_exec)
403
- result["status"] = "error"
404
- elif exec_result.error_before_exec:
405
- result["error"] = str(exec_result.error_before_exec)
406
- result["status"] = "error"
407
- else:
408
- # Fallback to exec() if IPython is not available
409
- with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
410
- # Compile to check for expression vs statement
411
- code = cell["source"].strip()
412
-
413
- # Try to evaluate as expression first (to get return value)
414
- try:
415
- # Check if it's a simple expression
416
- compiled = compile(code, "<cell>", "eval")
417
- exec_result = eval(compiled, self._namespace)
418
- if exec_result is not None:
419
- result["result"] = repr(exec_result)
420
- except SyntaxError:
421
- # It's a statement, execute it
422
- exec(code, self._namespace)
423
-
424
- result["stdout"] = stdout_capture.getvalue()
425
- result["stderr"] = stderr_capture.getvalue()
426
-
456
+ # Fallback to exec() if IPython is not available
457
+ with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
458
+ # Compile to check for expression vs statement
459
+ code = cell["source"].strip()
460
+
461
+ # Try to evaluate as expression first (to get return value)
462
+ try:
463
+ # Check if it's a simple expression
464
+ compiled = compile(code, "<cell>", "eval")
465
+ exec_result = eval(compiled, self._namespace)
466
+ if exec_result is not None:
467
+ result["result"] = repr(exec_result)
468
+ except SyntaxError:
469
+ # It's a statement, execute it
470
+ exec(code, self._namespace)
471
+
472
+ result["stdout"] = stdout_capture.getvalue()
473
+ result["stderr"] = stderr_capture.getvalue()
474
+
475
+ except MemoryError:
476
+ result["error"] = f"MemoryError: Cell execution exceeded memory limit ({CELL_MEMORY_LIMIT_MB} MB). Try processing data in smaller chunks."
477
+ result["status"] = "error"
478
+ result["stdout"] = stdout_capture.getvalue()
479
+ result["stderr"] = stderr_capture.getvalue()
427
480
  except Exception:
428
481
  result["error"] = traceback.format_exc()
429
482
  result["status"] = "error"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cowork-dash
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: AI Agent Web Interface with Filesystem and Canvas Visualization
5
5
  Project-URL: Homepage, https://github.com/dkedar7/cowork-dash
6
6
  Project-URL: Documentation, https://github.com/dkedar7/cowork-dash/blob/main/README.md
@@ -1,22 +1,22 @@
1
1
  cowork_dash/__init__.py,sha256=37qBKl7g12Zos8GFukXLXligCdpD32e2qe9F8cd8Qdk,896
2
2
  cowork_dash/__main__.py,sha256=CCM9VIkWuwh7hwVGNBBgCCbeVAcHj1soyBVXUaPgABk,131
3
3
  cowork_dash/agent.py,sha256=JhCeQuMJj3VaQij2i8OPznKN6qGAixN-hakueiGfdt0,5851
4
- cowork_dash/app.py,sha256=a7Z1N81h5BbI0rYfvy4_j4RDepVVTzJ622ma4KFjBec,109947
4
+ cowork_dash/app.py,sha256=5Fd2MZThIovq3uxf2qNq1i9lEKIPtmvUHO3ikk3k4Mc,122674
5
5
  cowork_dash/backends.py,sha256=YQE8f65o2qGxIIfvBITAS6krCLjl8D9UixW-pgbdgZk,15050
6
- cowork_dash/canvas.py,sha256=TNDku1AKpkajj1RgJW3zUDIq45H3M2RNR1plcsBS3dc,16969
6
+ cowork_dash/canvas.py,sha256=sYOQ5WBLm29UazA5KO7j8jvIeQpx55LToz1o44o2U-k,17261
7
7
  cowork_dash/cli.py,sha256=E7H9p21XcvuF6Kae4rjVVOavU7U8-TY0qFLHLPOtYWg,7125
8
8
  cowork_dash/components.py,sha256=SlR0U1d0XThdcPZYButE_Fqt8MWPoLAgOTqtwfisvOQ,23776
9
9
  cowork_dash/config.py,sha256=PoAxUbctyd_2JjH4eVffKhOPCEOvaRyPSzCmS4MFPi8,4570
10
- cowork_dash/file_utils.py,sha256=KwYHOtVkp-anysqktGsLvtb3yg-N4e3QhnliSfvFr6o,12533
10
+ cowork_dash/file_utils.py,sha256=mhy7gZFxwd1YN28C8jxYrtTCRLVDx4JVqELN-KCdtu8,13368
11
11
  cowork_dash/layout.py,sha256=6K6O7rxnYxcrIzMOQYtyth12wRGcZ_l8qKaZFvbxvRo,16634
12
- cowork_dash/tools.py,sha256=ywfF1EVA8swI81z9yBwU0U7sA9ZzwiqrYjlyV1ygDN4,33275
12
+ cowork_dash/tools.py,sha256=AOzO4_dl_mvTKr9bL0ibg0BUD53axm_Nfnth7io8bl0,35357
13
13
  cowork_dash/virtual_fs.py,sha256=PAAdRiMkxgJ4xPpGUyAUd69KbH-nFlt-FjldfB1FrQ4,16296
14
14
  cowork_dash/assets/app.js,sha256=Fo_tgFat9eXtpvrptLHmS0qAs04pfF0IaQYiT-Tohm8,8768
15
15
  cowork_dash/assets/favicon.ico,sha256=IiP0rVr0m-TBGGmCY88SyFC14drNwDhLRqb0n2ZufKk,54252
16
16
  cowork_dash/assets/favicon.svg,sha256=MdT50APCvIlWh3HSwW5SNXYWB3q_wKfuLP-JV53SnKg,1065
17
17
  cowork_dash/assets/styles.css,sha256=w4D9jAneSUjCZxdXu1tIbbKPt_HnNF0nax4hy9liOHw,24095
18
- cowork_dash-0.1.8.dist-info/METADATA,sha256=cdWkiguLARGvtAyg61S8fmhuk2nseXgU37rNeYDSXgY,6719
19
- cowork_dash-0.1.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
20
- cowork_dash-0.1.8.dist-info/entry_points.txt,sha256=lL_9XJINiky3nh13tLqWd61LitKbbyh085ROATH9fck,53
21
- cowork_dash-0.1.8.dist-info/licenses/LICENSE,sha256=2SFXFfIa_c_g_uwY0JApQDXI1mWqEfJeG87Pn4ehLMQ,1072
22
- cowork_dash-0.1.8.dist-info/RECORD,,
18
+ cowork_dash-0.1.9.dist-info/METADATA,sha256=Y1_ieezAgbVMwQoy7HovfZ9696BZvX3QZoXNeXXYAuE,6719
19
+ cowork_dash-0.1.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
20
+ cowork_dash-0.1.9.dist-info/entry_points.txt,sha256=lL_9XJINiky3nh13tLqWd61LitKbbyh085ROATH9fck,53
21
+ cowork_dash-0.1.9.dist-info/licenses/LICENSE,sha256=2SFXFfIa_c_g_uwY0JApQDXI1mWqEfJeG87Pn4ehLMQ,1072
22
+ cowork_dash-0.1.9.dist-info/RECORD,,