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.
70 lines
3.0 KiB
Python
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))
|