Files
hermes-agent/hermes_cli/web_routers/chat_workspaces.py
teknium1 d9ca819cc4 Inspired by Amp: pick the workspace a dashboard chat starts in
A fresh dashboard /chat always spawned the TUI in the dashboard process's
launch directory, so from a phone or any browser there was no way to aim
a new session at a specific repository. Amp's runners now serve many
directories and the web composer offers a picker of the runner's projects
and discovered git checkouts; this ports that mechanism onto the surface
Hermes already has: the dashboard is the phone/web front, the host's
projects.db + repo-discovery cache are the served directories.

- GET /api/chat/workspaces: the profile's projects (with folders) and
  discovered repos (session-derived + scanned), default_cwd, home;
  ?scan=1 rescans desktop.repo_scan_roots on the host, so headless
  installs (no Desktop to populate the cache) discover repos too.
- /api/pty?cwd=<dir>: validated (existing directory, fail-closed 400
  via the PTY error path) and forwarded to the TUI child as HERMES_CWD
  (self-spawned gateway cwd) + HERMES_TUI_CWD (explicit cwd on
  session.create for the dashboard's in-memory gateway, whose own cwd
  is the launch dir). Resumed sessions ignore it.
- ui-tui: session.create carries cwd when HERMES_TUI_CWD is set, so
  /new inside a dashboard chat stays in the picked workspace too.
- web: workspace selector in the Chat rail above "New chat" (projects,
  repos by recency, Other path…, rescan), remembered per profile in
  localStorage; 17 locales.
- docs: web-dashboard.md rail + REST sections.

Live E2E: real `hermes dashboard` under a scratch HOME with two git
repos under desktop.repo_scan_roots -> /api/chat/workspaces?scan=1
lists both; /api/pty?cwd=repo-a -> TUI status bar shows ~/code/repo-a;
/api/pty?cwd=<missing> -> "Working directory does not exist" + close.
2026-09-23 03:12:04 -07:00

70 lines
3.0 KiB
Python

"""Chat workspace picker: which host directories a NEW dashboard chat can start in.
The dashboard's ``/chat`` tab used to spawn every fresh TUI in the dashboard process's own
launch directory, so from a phone or browser there was no way to say "work in ~/code/foo".
This lists the same projects and discovered repositories the Desktop sidebar shows (the
per-profile ``projects.db`` plus session-derived and scanned git roots) so the SPA can offer
a picker, and ``/api/pty?cwd=`` (``chat_ws``) honours the choice.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from hermes_cli.web_deps import late
from hermes_cli.web_routers._common import http_failure, scoped_to_thread
router = APIRouter()
_open_session_db_for_profile = late("_open_session_db_for_profile", "hermes_cli.web_server_sessions")
def resolve_chat_cwd(raw: Optional[str]) -> Optional[str]:
"""Validate a ``?cwd=`` for a fresh chat: an existing host directory, or None when unset.
A dead or relative path fails closed (400) instead of silently falling back to the launch
dir — the user picked a workspace and would otherwise get a session in the wrong place.
"""
text = (raw or "").strip()
if not text:
return None
resolved = os.path.abspath(os.path.expanduser(text))
if not os.path.isdir(resolved):
raise HTTPException(status_code=400, detail=f"Working directory does not exist: {text}")
return resolved
def _collect_workspaces(profile: Optional[str], scan: bool) -> dict:
# The dashboard hosts the in-process gateway (``web_server`` imports ``tui_gateway.server``
# at startup), so the sidebar's repo-discovery helpers are already bound there.
import tui_gateway.server as gateway
from hermes_cli import projects_db as pdb
db = _open_session_db_for_profile(profile, read_only=True)
try:
with pdb.connect_closing() as conn:
policy = gateway._repo_discovery_policy()
if scan and policy["enabled"]:
gateway._scan_discovered_repos_remote(conn, policy)
projects = [p.to_dict() for p in pdb.list_projects(conn)]
repos = gateway._discover_repos_payload(
db, conn=conn, backfill=False, include_cached=policy["enabled"])
finally:
db.close()
default_cwd = gateway._completion_cwd({"profile": profile} if profile else {})
return {
"projects": projects, "repos": repos, "default_cwd": default_cwd,
"home": str(Path.home()), "scan_enabled": bool(policy["enabled"])}
@router.get("/api/chat/workspaces")
async def get_chat_workspaces(profile: Optional[str] = None, scan: bool = False):
"""Projects + discovered repos a fresh chat may start in; ``scan=1`` rescans the
configured discovery roots on the host first (headless installs have no Desktop to do it)."""
with http_failure("GET /api/chat/workspaces failed", 500, "Failed to list workspaces"):
return await scoped_to_thread(profile, lambda: _collect_workspaces(profile, scan))