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.
This commit is contained in:
teknium1
2026-09-20 20:34:41 -07:00
committed by Teknium
parent a2fbbf482d
commit d9ca819cc4
31 changed files with 646 additions and 4 deletions

View File

@@ -297,7 +297,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
def _resolve_chat_argv(
resume: Optional[str] = None, sidecar_url: Optional[str] = None, profile: Optional[str] = None,
active_session_file: Optional[str] = None) -> tuple[list[str], Optional[str], Optional[dict]]:
active_session_file: Optional[str] = None,
workspace_cwd: Optional[str] = None) -> tuple[list[str], Optional[str], Optional[dict]]:
"""Resolve the argv + cwd + env for the chat PTY (what ``hermes --tui`` runs).
Tests monkeypatch this with a tiny fake command. Env contract: resume goes
@@ -306,7 +307,12 @@ def _resolve_chat_argv(
in-memory gateway but is SKIPPED for profile-scoped chats (that gateway runs
under the dashboard's own profile, so a scoped chat spawns its own);
``profile`` scopes the ENTIRE chat by pointing ``HERMES_HOME`` at the profile
dir, the same propagation ``hermes -p <name>`` performs.
dir, the same propagation ``hermes -p <name>`` performs. ``workspace_cwd``
(an already-validated host directory, ``chat_workspaces.resolve_chat_cwd``)
is the workspace the user picked for a FRESH chat: it becomes ``HERMES_CWD``
(where a self-spawned gateway starts) and ``HERMES_TUI_CWD`` (what the TUI
passes as the explicit ``cwd`` of ``session.create`` when attached to the
in-memory gateway, whose own cwd is the dashboard's launch dir).
"""
from hermes_cli.web_server_profiles import _config_profile_scope, _resolve_profile_dir
from hermes_cli.web_server_sessions import _open_session_db_for_profile, _session_latest_descendant
@@ -341,6 +347,9 @@ def _resolve_chat_argv(
apply_terminal_config_to_env(env=env)
except Exception:
_log.warning("Failed to apply terminal config bridge for dashboard chat", exc_info=True)
if workspace_cwd:
env["HERMES_CWD"] = workspace_cwd
env["HERMES_TUI_CWD"] = workspace_cwd
_apply_tui_python_env(env)
env.setdefault("NODE_ENV", "production")
# Mouse tracking would swallow wheel events the browser needs for
@@ -430,13 +439,16 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
async def _resolve_chat_argv_async(
resume: Optional[str] = None, sidecar_url: Optional[str] = None, profile: Optional[str] = None,
active_session_file: Optional[str] = None) -> tuple[list[str], Optional[str], Optional[dict]]:
active_session_file: Optional[str] = None,
workspace_cwd: Optional[str] = None) -> tuple[list[str], Optional[str], Optional[dict]]:
"""Resolve chat argv off the event loop (it may run ``npm run build``); the
async lock keeps one-build-at-a-time without parking worker threads."""
from hermes_cli.web_server import _get_chat_argv_lock, app
kwargs = {"resume": resume, "sidecar_url": sidecar_url, "profile": profile}
if active_session_file is not None:
kwargs["active_session_file"] = active_session_file
if workspace_cwd is not None:
kwargs["workspace_cwd"] = workspace_cwd
async with _get_chat_argv_lock(app):
return await asyncio.to_thread(_resolve_chat_argv, **kwargs)