From d9ca819cc4902fe0a90d1c30d89350ec889dcfbf Mon Sep 17 00:00:00 2001
From: teknium1 <127238744+teknium1@users.noreply.github.com>
Date: Sun, 20 Sep 2026 20:34:41 -0700
Subject: [PATCH] Inspired by Amp: pick the workspace a dashboard chat starts
in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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=
: 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= -> "Working directory does not exist" + close.
---
hermes_cli/web_routers/chat_workspaces.py | 69 ++++++++
hermes_cli/web_routers/chat_ws.py | 10 ++
hermes_cli/web_server.py | 2 +
hermes_cli/web_server_chat.py | 18 +-
.../test_web_server_chat_workspaces.py | 79 +++++++++
ui-tui/src/app/useSessionLifecycle.ts | 6 +-
ui-tui/src/config/env.ts | 3 +
web/src/components/ChatSessionList.tsx | 19 +++
web/src/components/ChatWorkspacePicker.tsx | 159 ++++++++++++++++++
web/src/i18n/af.ts | 4 +
web/src/i18n/de.ts | 4 +
web/src/i18n/en.ts | 4 +
web/src/i18n/es.ts | 4 +
web/src/i18n/fr.ts | 4 +
web/src/i18n/ga.ts | 4 +
web/src/i18n/hu.ts | 4 +
web/src/i18n/it.ts | 4 +
web/src/i18n/ja.ts | 4 +
web/src/i18n/ko.ts | 4 +
web/src/i18n/pt.ts | 4 +
web/src/i18n/ru.ts | 4 +
web/src/i18n/tr.ts | 4 +
web/src/i18n/types.ts | 4 +
web/src/i18n/uk.ts | 4 +
web/src/i18n/zh-hant.ts | 4 +
web/src/i18n/zh.ts | 4 +
web/src/lib/api.ts | 39 +++++
web/src/lib/chat-workspaces.test.ts | 61 +++++++
web/src/lib/chat-workspaces.ts | 81 +++++++++
web/src/pages/ChatPage.tsx | 30 ++++
.../docs/user-guide/features/web-dashboard.md | 6 +
31 files changed, 646 insertions(+), 4 deletions(-)
create mode 100644 hermes_cli/web_routers/chat_workspaces.py
create mode 100644 tests/hermes_cli/test_web_server_chat_workspaces.py
create mode 100644 web/src/components/ChatWorkspacePicker.tsx
create mode 100644 web/src/lib/chat-workspaces.test.ts
create mode 100644 web/src/lib/chat-workspaces.ts
diff --git a/hermes_cli/web_routers/chat_workspaces.py b/hermes_cli/web_routers/chat_workspaces.py
new file mode 100644
index 0000000000..fc63eb3556
--- /dev/null
+++ b/hermes_cli/web_routers/chat_workspaces.py
@@ -0,0 +1,69 @@
+"""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))
diff --git a/hermes_cli/web_routers/chat_ws.py b/hermes_cli/web_routers/chat_ws.py
index 6a827f7f94..dcf6923a5d 100644
--- a/hermes_cli/web_routers/chat_ws.py
+++ b/hermes_cli/web_routers/chat_ws.py
@@ -478,6 +478,16 @@ async def pty_ws(ws: WebSocket) -> None:
resolve_kwargs = {"resume": resume, "sidecar_url": sidecar_url, "profile": profile}
if active_session_file is not None:
resolve_kwargs["active_session_file"] = str(active_session_file)
+ # A picked workspace only applies to a FRESH chat; a resumed session keeps its own cwd.
+ if not resume:
+ from hermes_cli.web_routers.chat_workspaces import resolve_chat_cwd
+ try:
+ workspace_cwd = resolve_chat_cwd(ws.query_params.get("cwd"))
+ except HTTPException as exc: # dead/relative path: fail closed, never the launch dir
+ await _pty_fail(ws, exc)
+ return
+ if workspace_cwd:
+ resolve_kwargs["workspace_cwd"] = workspace_cwd
try:
argv, cwd, env = await _resolve_chat_argv_async(**resolve_kwargs)
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index e6d7c8fe70..f4c4140c67 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -957,6 +957,7 @@ from hermes_cli.web_routers import ( # noqa: E402
tools as _tools_routes,
analytics as _analytics_routes,
chat_ws as _chat_ws_routes,
+ chat_workspaces as _chat_workspaces_routes,
dashboard_ui as _dashboard_ui_routes,
)
@@ -987,6 +988,7 @@ app.include_router(_skills_routes.router)
app.include_router(_tools_routes.router)
app.include_router(_analytics_routes.router)
app.include_router(_chat_ws_routes.router)
+app.include_router(_chat_workspaces_routes.router)
app.include_router(_dashboard_ui_routes.router)
# Plugin API routes and the dashboard auth routes (/login, /auth/*, /api/auth/*)
diff --git a/hermes_cli/web_server_chat.py b/hermes_cli/web_server_chat.py
index 047bc867af..32f9526b15 100644
--- a/hermes_cli/web_server_chat.py
+++ b/hermes_cli/web_server_chat.py
@@ -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 `` performs.
+ dir, the same propagation ``hermes -p `` 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)
diff --git a/tests/hermes_cli/test_web_server_chat_workspaces.py b/tests/hermes_cli/test_web_server_chat_workspaces.py
new file mode 100644
index 0000000000..ec28be90a2
--- /dev/null
+++ b/tests/hermes_cli/test_web_server_chat_workspaces.py
@@ -0,0 +1,79 @@
+"""Dashboard chat workspace picker: ``GET /api/chat/workspaces`` + ``/api/pty?cwd=``.
+
+A fresh dashboard chat can be aimed at a host directory (the same projects/repos the
+Desktop sidebar lists); the pick reaches the TUI as ``HERMES_CWD``/``HERMES_TUI_CWD`` and a
+dead path fails closed instead of silently landing in the dashboard's launch dir.
+"""
+
+from pathlib import Path
+
+import pytest
+from fastapi import HTTPException
+
+from hermes_cli import web_server, web_server_chat
+from hermes_cli.web_routers import chat_workspaces
+
+pytest.importorskip("starlette.testclient")
+from starlette.testclient import TestClient
+
+
+@pytest.fixture
+def client():
+ previous = getattr(web_server.app.state, "auth_required", None)
+ web_server.app.state.auth_required = False
+ test_client = TestClient(web_server.app)
+ test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
+ try:
+ yield test_client
+ finally:
+ if previous is None:
+ try:
+ delattr(web_server.app.state, "auth_required")
+ except AttributeError:
+ pass
+ else:
+ web_server.app.state.auth_required = previous
+
+
+def test_workspaces_lists_projects_and_discovered_repos(client, tmp_path):
+ """Explicit project folders AND the discovery cache both surface; the payload names the
+ default cwd a chat lands in when nothing is picked."""
+ from hermes_cli import projects_db as pdb
+
+ proj_dir = tmp_path / "proj"
+ proj_dir.mkdir()
+ repo_dir = tmp_path / "scanned"
+ repo_dir.mkdir()
+ with pdb.connect_closing() as conn:
+ pdb.create_project(conn, name="Demo", folders=[str(proj_dir)])
+ pdb.record_discovered_repos(conn, [(str(repo_dir), "scanned")], replace=True, policy_key="k")
+
+ body = client.get("/api/chat/workspaces").json()
+
+ assert [p["name"] for p in body["projects"]] == ["Demo"]
+ assert body["projects"][0]["folders"][0]["path"] == str(proj_dir)
+ assert str(repo_dir) in {r["root"] for r in body["repos"]}
+ assert Path(body["default_cwd"]).is_dir()
+ assert body["home"] == str(Path.home())
+
+
+def test_resolve_chat_cwd_fails_closed_and_reaches_the_tui_env(monkeypatch, tmp_path):
+ """A picked directory becomes HERMES_CWD + HERMES_TUI_CWD on the PTY child; a missing one is a
+ 400 (never the launch-dir fallback); unset means no override."""
+ import hermes_cli.main_tui_launch as tui_launch
+
+ monkeypatch.setattr(
+ tui_launch, "_make_tui_argv", lambda *_a, **_k: (["node", "fake-tui.js"], tmp_path))
+
+ assert chat_workspaces.resolve_chat_cwd("") is None
+ with pytest.raises(HTTPException) as exc:
+ chat_workspaces.resolve_chat_cwd(str(tmp_path / "gone"))
+ assert exc.value.status_code == 400
+
+ picked = chat_workspaces.resolve_chat_cwd(str(tmp_path))
+ _argv, _cwd, env = web_server_chat._resolve_chat_argv(workspace_cwd=picked)
+ assert env["HERMES_CWD"] == str(tmp_path)
+ assert env["HERMES_TUI_CWD"] == str(tmp_path)
+
+ _argv, _cwd, env = web_server_chat._resolve_chat_argv()
+ assert "HERMES_TUI_CWD" not in env
diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts
index 5c89b56962..09314d14e9 100644
--- a/ui-tui/src/app/useSessionLifecycle.ts
+++ b/ui-tui/src/app/useSessionLifecycle.ts
@@ -5,6 +5,7 @@ import { evictInkCaches } from '@hermes/ink'
import type { InflightTurn, SessionResumeResult, Usage } from '@hermes/shared/gateway-events'
import { type RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
+import { STARTUP_WORKSPACE_CWD } from '../config/env.js'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
import { ZERO } from '../domain/usage.js'
@@ -210,7 +211,10 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
await closeSession(previousSid)
}
- const r = await rpc('session.create', { cols: colsRef.current })
+ const r = await rpc('session.create', {
+ cols: colsRef.current,
+ ...(STARTUP_WORKSPACE_CWD ? { cwd: STARTUP_WORKSPACE_CWD } : {})
+ })
if (!r) {
patchUiState({ status: 'ready' })
diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts
index 426e6459ca..f4a2bcb085 100644
--- a/ui-tui/src/config/env.ts
+++ b/ui-tui/src/config/env.ts
@@ -28,6 +28,9 @@ export const TERMUX_TUI_MODE = isTermuxTuiMode()
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim()
export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
+// Workspace the dashboard user picked for a fresh chat (`/api/pty?cwd=`). Sent as the explicit
+// `cwd` of session.create: the in-memory gateway's own cwd is the dashboard's launch dir.
+export const STARTUP_WORKSPACE_CWD = (process.env.HERMES_TUI_CWD ?? '').trim()
// Mouse tracking mode resolution at startup. Per-mode selection (off|wheel|
// buttons|all) lives in display.mouse_tracking in config.yaml — these env
diff --git a/web/src/components/ChatSessionList.tsx b/web/src/components/ChatSessionList.tsx
index 0544fa5420..e9315e85b0 100644
--- a/web/src/components/ChatSessionList.tsx
+++ b/web/src/components/ChatSessionList.tsx
@@ -25,6 +25,7 @@ import { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
+import { ChatWorkspacePicker } from "@/components/ChatWorkspacePicker";
import { useI18n } from "@/i18n";
import { api, type SessionInfo } from "@/lib/api";
import { cn, timeAgo } from "@/lib/utils";
@@ -45,6 +46,14 @@ interface ChatSessionListProps {
* omitted, we fall back to clearing the resume param ourselves.
*/
onNewChat?: () => void;
+ /**
+ * Workspace a FRESH chat starts in (absolute host path, "" = server
+ * default). Owned by ChatPage, which sends it as `/api/pty?cwd=`; the
+ * picker renders here beside "New chat" so the choice sits next to the
+ * action it affects. Omit both to hide the picker.
+ */
+ workspaceCwd?: string;
+ onWorkspaceChange?: (cwd: string) => void;
}
function rowLabel(session: SessionInfo, untitled: string): string {
@@ -61,6 +70,8 @@ export function ChatSessionList({
className,
onPicked,
onNewChat,
+ workspaceCwd,
+ onWorkspaceChange,
}: ChatSessionListProps) {
const { t } = useI18n();
const [, setSearchParams] = useSearchParams();
@@ -242,6 +253,14 @@ export function ChatSessionList({
+ {onWorkspaceChange && (
+
+ )}
+