Skip to content

feat: add ChatUI project workspaces - #9066

Merged
Soulter merged 5 commits into
AstrBotDevs:masterfrom
Soulter:codex/chatui-project-workspaces
Jul 3, 2026
Merged

Soulter merged 5 commits into
AstrBotDevs:masterfrom
Soulter:codex/chatui-project-workspaces

Conversation

@Soulter

@Soulter Soulter commented Jun 28, 2026 •

Copy link
Copy Markdown
Member

Summary

  • add ChatUI project workspace modes and route fs/python/shell/message tools through the resolved workspace
  • reorganize the ChatUI sidebar into project and conversation sections with project session management
  • polish the ChatUI header, mobile sidebar behavior, mobile composer, right panels, and project dialog workspace validation

Tests

  • uv run ruff format .
  • uv run ruff check .
  • uv run pytest tests/unit/test_chatui_project_service.py tests/unit/test_python_tools.py
  • cd dashboard && pnpm typecheck

Summary by Sourcery

Introduce workspace-aware ChatUI projects with improved header, sidebar, and mobile chat layout while routing local tools through the resolved workspace.

New Features:

  • Add project workspace modes (session, project, custom) with backend and database support, including workspace configuration on ChatUI projects.
  • Expose project workspace configuration in the API schema and UI, allowing users to choose and validate custom workspace paths.
  • Display workspace summaries in the project view and propagate project/session context to a dedicated chat header store.

Enhancements:

  • Refine ChatUI sidebar into distinct project and conversation sections with session lists under each project and persistent project expansion state.
  • Update chat header behavior and styling for chat routes, including responsive alignment with the sidebar and contextual title/subtitle display.
  • Improve mobile chat composer, sidebar drawer behavior, and right-side panels layout for better responsiveness and visual consistency.
  • Route filesystem, shell, python, and message tools through the resolved workspace root instead of legacy per-session paths, while keeping permission checks aligned with the active workspace.

Tests:

  • Add unit tests for ChatUI project workspace validation and adjust existing python tools tests to cover workspace-root resolution via context.

@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. feature:chatui The bug / feature is about astrbot's chatui, webchat labels Jun 28, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/tools/computer_tools/fs.py" line_range="80" />
<code_context>


-def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]:
+def _restricted_env_path_labels(
+    umo: str,
+    *,
</code_context>
<issue_to_address>
**issue (complexity):** Consider introducing a single effective workspace root helper, passing a concrete root instead of threading optional state, and splitting path normalization from authorization to simplify the filesystem tooling logic.

You can keep the new “current workspace” behavior but reduce complexity by centralizing workspace resolution and separating concerns a bit.

### 1. Centralize workspace resolution

Instead of repeating `(current_workspace_root or _workspace_root(umo))` in multiple helpers, introduce one small helper:

```python
def _effective_workspace_root(umo: str, current_workspace_root: Path | None) -> Path:
    return (current_workspace_root or _workspace_root(umo)).resolve(strict=False)
```

Then in helpers that currently accept both `umo` and `current_workspace_root`, call this once:

```python
def _read_allowed_roots(umo: str, current_workspace_root: Path | None) -> tuple[Path, ...]:
    workspace_root = _effective_workspace_root(umo, current_workspace_root)
    return (
        Path(get_astrbot_skills_path()).resolve(strict=False),
        *_plugin_skill_roots(),
        workspace_root,
        Path(get_astrbot_system_tmp_path()).resolve(strict=False),
        Path(get_astrbot_temp_path()).resolve(strict=False),
    )
```

Apply the same pattern to `_write_allowed_roots`, `_restricted_env_path_labels`, `_resolve_tool_path`, `_resolve_user_path`, `_is_path_within_allowed_roots`, etc., instead of open-coding the `(current_workspace_root or _workspace_root(umo))` expression.

### 2. Pass a concrete `effective_root` instead of threading `current_workspace_root`

At the tool entrypoints, compute the effective root once and pass that down, rather than passing `umo` + `current_workspace_root` everywhere:

```python
async def call(...):
    umo = context.context.event.unified_msg_origin
    current_workspace_root = workspace_root_for_context(context)  # existing helper
    effective_root = _effective_workspace_root(umo, current_workspace_root)

    normalized_path = (
        _normalize_rw_path(
            path,
            restricted=restricted,
            local_env=local_env,
            workspace_root=effective_root,
            write=True,
        )
        if local_env else path.strip()
    )
```

Then change helpers to accept `workspace_root: Path` instead of both `umo` and `current_workspace_root`:

```python
def _resolve_tool_path(path: str, *, local_env: bool, workspace_root: Path) -> str:
    normalized_path = path.strip()
    if not normalized_path:
        return normalized_path
    candidate = Path(normalized_path).expanduser()
    if candidate.is_absolute():
        return str(candidate.resolve(strict=False))
    if local_env:
        return str((workspace_root / candidate).resolve(strict=False))
    return normalized_path
```

This keeps the feature intact but removes optional-threading and makes call sites easier to follow.

### 3. Separate normalization and authorization

Right now `_normalize_rw_path` and `_normalize_search_paths` both resolve paths and enforce allowed-roots. Splitting them into two small helpers reduces parameter coupling:

```python
def _normalize_path_for_env(
    path: str,
    *,
    local_env: bool,
    workspace_root: Path,
) -> str:
    return _resolve_tool_path(path, local_env=local_env, workspace_root=workspace_root)


def _ensure_rw_authorized(
    normalized_path: str,
    *,
    restricted: bool,
    workspace_root: Path,
    write: bool,
) -> None:
    if not restricted:
        return

    allowed_roots = (
        _write_allowed_roots_for_root(workspace_root)
        if write
        else _read_allowed_roots_for_root(workspace_root)
    )
    if not _is_path_within_allowed_roots(normalized_path, allowed_roots=allowed_roots):
        # build labels from workspace_root rather than recomputing
        ...
    _reject_multi_link_file(normalized_path)
```

And then `_normalize_rw_path` becomes a slim orchestrator:

```python
def _normalize_rw_path(
    path: str,
    *,
    restricted: bool,
    local_env: bool,
    workspace_root: Path,
    write: bool = False,
) -> str:
    normalized_path = _normalize_path_for_env(
        path, local_env=local_env, workspace_root=workspace_root
    )
    if not normalized_path:
        raise ValueError("`path` must be a non-empty string.")
    _ensure_rw_authorized(
        normalized_path,
        restricted=restricted,
        workspace_root=workspace_root,
        write=write,
    )
    return normalized_path
```

This keeps all existing semantics (including current workspace resolution) while reducing branching and repeated resolution logic.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.



def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]:
def _restricted_env_path_labels(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider introducing a single effective workspace root helper, passing a concrete root instead of threading optional state, and splitting path normalization from authorization to simplify the filesystem tooling logic.

You can keep the new “current workspace” behavior but reduce complexity by centralizing workspace resolution and separating concerns a bit.

1. Centralize workspace resolution

Instead of repeating (current_workspace_root or _workspace_root(umo)) in multiple helpers, introduce one small helper:

def _effective_workspace_root(umo: str, current_workspace_root: Path | None) -> Path:
    return (current_workspace_root or _workspace_root(umo)).resolve(strict=False)

Then in helpers that currently accept both umo and current_workspace_root, call this once:

def _read_allowed_roots(umo: str, current_workspace_root: Path | None) -> tuple[Path, ...]:
    workspace_root = _effective_workspace_root(umo, current_workspace_root)
    return (
        Path(get_astrbot_skills_path()).resolve(strict=False),
        *_plugin_skill_roots(),
        workspace_root,
        Path(get_astrbot_system_tmp_path()).resolve(strict=False),
        Path(get_astrbot_temp_path()).resolve(strict=False),
    )

Apply the same pattern to _write_allowed_roots, _restricted_env_path_labels, _resolve_tool_path, _resolve_user_path, _is_path_within_allowed_roots, etc., instead of open-coding the (current_workspace_root or _workspace_root(umo)) expression.

2. Pass a concrete effective_root instead of threading current_workspace_root

At the tool entrypoints, compute the effective root once and pass that down, rather than passing umo + current_workspace_root everywhere:

async def call(...):
    umo = context.context.event.unified_msg_origin
    current_workspace_root = workspace_root_for_context(context)  # existing helper
    effective_root = _effective_workspace_root(umo, current_workspace_root)

    normalized_path = (
        _normalize_rw_path(
            path,
            restricted=restricted,
            local_env=local_env,
            workspace_root=effective_root,
            write=True,
        )
        if local_env else path.strip()
    )

Then change helpers to accept workspace_root: Path instead of both umo and current_workspace_root:

def _resolve_tool_path(path: str, *, local_env: bool, workspace_root: Path) -> str:
    normalized_path = path.strip()
    if not normalized_path:
        return normalized_path
    candidate = Path(normalized_path).expanduser()
    if candidate.is_absolute():
        return str(candidate.resolve(strict=False))
    if local_env:
        return str((workspace_root / candidate).resolve(strict=False))
    return normalized_path

This keeps the feature intact but removes optional-threading and makes call sites easier to follow.

3. Separate normalization and authorization

Right now _normalize_rw_path and _normalize_search_paths both resolve paths and enforce allowed-roots. Splitting them into two small helpers reduces parameter coupling:

def _normalize_path_for_env(
    path: str,
    *,
    local_env: bool,
    workspace_root: Path,
) -> str:
    return _resolve_tool_path(path, local_env=local_env, workspace_root=workspace_root)


def _ensure_rw_authorized(
    normalized_path: str,
    *,
    restricted: bool,
    workspace_root: Path,
    write: bool,
) -> None:
    if not restricted:
        return

    allowed_roots = (
        _write_allowed_roots_for_root(workspace_root)
        if write
        else _read_allowed_roots_for_root(workspace_root)
    )
    if not _is_path_within_allowed_roots(normalized_path, allowed_roots=allowed_roots):
        # build labels from workspace_root rather than recomputing
        ...
    _reject_multi_link_file(normalized_path)

And then _normalize_rw_path becomes a slim orchestrator:

def _normalize_rw_path(
    path: str,
    *,
    restricted: bool,
    local_env: bool,
    workspace_root: Path,
    write: bool = False,
) -> str:
    normalized_path = _normalize_path_for_env(
        path, local_env=local_env, workspace_root=workspace_root
    )
    if not normalized_path:
        raise ValueError("`path` must be a non-empty string.")
    _ensure_rw_authorized(
        normalized_path,
        restricted=restricted,
        workspace_root=workspace_root,
        write=write,
    )
    return normalized_path

This keeps all existing semantics (including current workspace resolution) while reducing branching and repeated resolution logic.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for project-level and custom workspaces in AstrBot, updating the database schema, backend path resolution logic, and the frontend dashboard to allow users to configure and view shared or custom workspace paths. The review feedback highlights a critical security vulnerability in workspace_path_to_root where path traversal or arbitrary absolute paths could bypass the sandbox and allow unauthorized file access. Additionally, a potential TypeError was identified in the frontend ProjectDialog.vue when calling .trim() on a potentially null or undefined workspace_path.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread astrbot/core/workspace.py Outdated
Comment on lines +85 to 89
const canSave = computed(() => {
if (!form.value.title.trim()) return false;
if (form.value.workspace_type !== 'custom') return true;
return form.value.workspace_path.trim().length > 0;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensive Programming: Prevent potential TypeError on null/undefined workspace_path

If form.value.workspace_path is somehow set to null or undefined (e.g., if cleared or through binding), calling .trim() directly on it will throw a TypeError. Using a fallback empty string (form.value.workspace_path || '') ensures robustness.

const canSave = computed(() => {
    if (!form.value.title.trim()) return false;
    if (form.value.workspace_type !== 'custom') return true;
    return (form.value.workspace_path || '').trim().length > 0;
});

@Soulter
Soulter force-pushed the codex/chatui-project-workspaces branch 6 times, most recently from 3f3c042 to 298bb75 Compare July 3, 2026 08:41
@Soulter
Soulter force-pushed the codex/chatui-project-workspaces branch from 298bb75 to b6038cb Compare July 3, 2026 08:47
@Soulter
Soulter merged commit e7d5be6 into AstrBotDevs:master Jul 3, 2026
20 of 21 checks passed
KBVsent pushed a commit to KBVsent/AstrBot that referenced this pull request Jul 13, 2026
* feat: add ChatUI project workspaces

* feat: refine chatui layout

* feat: enhance chat input and project view styles for attachment handling

* feat: adjust chat sidebar brand logo positioning

* feat: enhance project dialog with error handling and loading state
KBVsent pushed a commit to KBVsent/AstrBot that referenced this pull request Jul 13, 2026
* feat: add ChatUI project workspaces

* feat: refine chatui layout

* feat: enhance chat input and project view styles for attachment handling

* feat: adjust chat sidebar brand logo positioning

* feat: enhance project dialog with error handling and loading state
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. feature:chatui The bug / feature is about astrbot's chatui, webchat size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant