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 && ( + + )} + + + + {customOpen && ( + setDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={(e) => { + if (e.key === "Enter") commitDraft(); + if (e.key === "Escape") setCustomOpen(false); + }} + className="h-8 text-xs" + /> + )} + + ); +} diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts index 29c9abf230..7148f9c4a8 100644 --- a/web/src/i18n/af.ts +++ b/web/src/i18n/af.ts @@ -165,6 +165,10 @@ export const af: Translations = { failedToDeleteSelected: "Kon nie gekose sessies skrap nie", resumeInChat: "Hervat in Klets", newChat: "Nuwe klets", + workspace: "werkruimte", + workspaceDefault: "Verstek", + workspaceRescan: "Herskandeer bewaarplekke", + workspaceCustom: "Ander pad…", previousPage: "Vorige bladsy", nextPage: "Volgende bladsy", roles: { diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 183037a467..7c0a1bd8df 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -165,6 +165,10 @@ export const de: Translations = { failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden", resumeInChat: "Im Chat fortsetzen", newChat: "Neuer Chat", + workspace: "Arbeitsbereich", + workspaceDefault: "Standard", + workspaceRescan: "Repositories neu scannen", + workspaceCustom: "Anderer Pfad…", previousPage: "Vorherige Seite", nextPage: "Nächste Seite", roles: { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 41daace2aa..d2cfa1ba8b 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -194,6 +194,10 @@ export const en: Translations = { failedToDeleteSelected: "Failed to delete selected sessions", resumeInChat: "Resume in Chat", newChat: "New chat", + workspace: "workspace", + workspaceDefault: "Default", + workspaceRescan: "Rescan repositories", + workspaceCustom: "Other path…", previousPage: "Previous page", nextPage: "Next page", roles: { diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index df429c9967..e6a716a37c 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -165,6 +165,10 @@ export const es: Translations = { failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas", resumeInChat: "Reanudar en el chat", newChat: "Nuevo chat", + workspace: "espacio de trabajo", + workspaceDefault: "Predeterminado", + workspaceRescan: "Volver a buscar repositorios", + workspaceCustom: "Otra ruta…", previousPage: "Página anterior", nextPage: "Página siguiente", roles: { diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts index 213bbdbb1f..ce8bfadee7 100644 --- a/web/src/i18n/fr.ts +++ b/web/src/i18n/fr.ts @@ -165,6 +165,10 @@ export const fr: Translations = { failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées", resumeInChat: "Reprendre dans le chat", newChat: "Nouveau chat", + workspace: "espace de travail", + workspaceDefault: "Par défaut", + workspaceRescan: "Rechercher à nouveau les dépôts", + workspaceCustom: "Autre chemin…", previousPage: "Page précédente", nextPage: "Page suivante", roles: { diff --git a/web/src/i18n/ga.ts b/web/src/i18n/ga.ts index 36815d968f..4fa2f66bc3 100644 --- a/web/src/i18n/ga.ts +++ b/web/src/i18n/ga.ts @@ -165,6 +165,10 @@ export const ga: Translations = { failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe", resumeInChat: "Lean ar aghaidh sa chomhrá", newChat: "Comhrá nua", + workspace: "spás oibre", + workspaceDefault: "Réamhshocrú", + workspaceRescan: "Athscan na stórtha", + workspaceCustom: "Cosán eile…", previousPage: "Leathanach roimhe seo", nextPage: "An chéad leathanach eile", roles: { diff --git a/web/src/i18n/hu.ts b/web/src/i18n/hu.ts index 625e2f728d..f980526e2a 100644 --- a/web/src/i18n/hu.ts +++ b/web/src/i18n/hu.ts @@ -165,6 +165,10 @@ export const hu: Translations = { failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket", resumeInChat: "Folytatás a csevegésben", newChat: "Új csevegés", + workspace: "munkaterület", + workspaceDefault: "Alapértelmezett", + workspaceRescan: "Tárolók újrakeresése", + workspaceCustom: "Másik útvonal…", previousPage: "Előző oldal", nextPage: "Következő oldal", roles: { diff --git a/web/src/i18n/it.ts b/web/src/i18n/it.ts index dd35370a44..a85a4c2576 100644 --- a/web/src/i18n/it.ts +++ b/web/src/i18n/it.ts @@ -165,6 +165,10 @@ export const it: Translations = { failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate", resumeInChat: "Riprendi nella chat", newChat: "Nuova chat", + workspace: "area di lavoro", + workspaceDefault: "Predefinito", + workspaceRescan: "Riesamina i repository", + workspaceCustom: "Altro percorso…", previousPage: "Pagina precedente", nextPage: "Pagina successiva", roles: { diff --git a/web/src/i18n/ja.ts b/web/src/i18n/ja.ts index aa9b777932..e93f8ae563 100644 --- a/web/src/i18n/ja.ts +++ b/web/src/i18n/ja.ts @@ -165,6 +165,10 @@ export const ja: Translations = { failedToDeleteSelected: "選択したセッションの削除に失敗しました", resumeInChat: "チャットで再開", newChat: "新しいチャット", + workspace: "ワークスペース", + workspaceDefault: "デフォルト", + workspaceRescan: "リポジトリを再スキャン", + workspaceCustom: "別のパス…", previousPage: "前のページ", nextPage: "次のページ", roles: { diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts index bdd3f1e597..297e8493a8 100644 --- a/web/src/i18n/ko.ts +++ b/web/src/i18n/ko.ts @@ -165,6 +165,10 @@ export const ko: Translations = { failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다", resumeInChat: "채팅에서 다시 시작", newChat: "새 채팅", + workspace: "작업 공간", + workspaceDefault: "기본값", + workspaceRescan: "저장소 다시 검색", + workspaceCustom: "다른 경로…", previousPage: "이전 페이지", nextPage: "다음 페이지", roles: { diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts index 5f77f8c1fd..711483f1e2 100644 --- a/web/src/i18n/pt.ts +++ b/web/src/i18n/pt.ts @@ -165,6 +165,10 @@ export const pt: Translations = { failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas", resumeInChat: "Retomar no Chat", newChat: "Novo chat", + workspace: "área de trabalho", + workspaceDefault: "Padrão", + workspaceRescan: "Reexaminar repositórios", + workspaceCustom: "Outro caminho…", previousPage: "Página anterior", nextPage: "Página seguinte", roles: { diff --git a/web/src/i18n/ru.ts b/web/src/i18n/ru.ts index df94e1380f..e2c469fc26 100644 --- a/web/src/i18n/ru.ts +++ b/web/src/i18n/ru.ts @@ -165,6 +165,10 @@ export const ru: Translations = { failedToDeleteSelected: "Не удалось удалить выбранные сессии", resumeInChat: "Продолжить в чате", newChat: "Новый чат", + workspace: "рабочая папка", + workspaceDefault: "По умолчанию", + workspaceRescan: "Пересканировать репозитории", + workspaceCustom: "Другой путь…", previousPage: "Предыдущая страница", nextPage: "Следующая страница", roles: { diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts index 5ea52416de..edef05053e 100644 --- a/web/src/i18n/tr.ts +++ b/web/src/i18n/tr.ts @@ -165,6 +165,10 @@ export const tr: Translations = { failedToDeleteSelected: "Seçilen oturumlar silinemedi", resumeInChat: "Sohbette Devam Et", newChat: "Yeni sohbet", + workspace: "çalışma alanı", + workspaceDefault: "Varsayılan", + workspaceRescan: "Depoları yeniden tara", + workspaceCustom: "Başka yol…", previousPage: "Önceki sayfa", nextPage: "Sonraki sayfa", roles: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index f96afda632..ea0597f475 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -208,6 +208,10 @@ export interface Translations { failedToDeleteSelected: string; resumeInChat: string; newChat: string; + workspace: string; + workspaceDefault: string; + workspaceRescan: string; + workspaceCustom: string; previousPage: string; nextPage: string; roles: { diff --git a/web/src/i18n/uk.ts b/web/src/i18n/uk.ts index c38f57b6b5..fd6dfadb78 100644 --- a/web/src/i18n/uk.ts +++ b/web/src/i18n/uk.ts @@ -165,6 +165,10 @@ export const uk: Translations = { failedToDeleteSelected: "Не вдалося видалити вибрані сесії", resumeInChat: "Продовжити в чаті", newChat: "Новий чат", + workspace: "робоча папка", + workspaceDefault: "За замовчуванням", + workspaceRescan: "Пересканувати репозиторії", + workspaceCustom: "Інший шлях…", previousPage: "Попередня сторінка", nextPage: "Наступна сторінка", roles: { diff --git a/web/src/i18n/zh-hant.ts b/web/src/i18n/zh-hant.ts index 85085604f1..57f8fab86b 100644 --- a/web/src/i18n/zh-hant.ts +++ b/web/src/i18n/zh-hant.ts @@ -165,6 +165,10 @@ export const zhHant: Translations = { failedToDeleteSelected: "刪除所選工作階段失敗", resumeInChat: "在對話中繼續", newChat: "新對話", + workspace: "工作區", + workspaceDefault: "預設", + workspaceRescan: "重新掃描儲存庫", + workspaceCustom: "其他路徑…", previousPage: "上一頁", nextPage: "下一頁", roles: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 7eeefea9f1..a1f934882c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -163,6 +163,10 @@ export const zh: Translations = { failedToDeleteSelected: "删除所选会话失败", resumeInChat: "在对话中继续", newChat: "新对话", + workspace: "工作区", + workspaceDefault: "默认", + workspaceRescan: "重新扫描仓库", + workspaceCustom: "其他路径…", previousPage: "上一页", nextPage: "下一页", roles: { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 47e063f37d..f9ff67aa43 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -462,6 +462,20 @@ export const api = { fetchJSON( appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile), ), + /** + * Directories a FRESH dashboard chat may start in: the profile's explicit + * projects plus discovered git repos (session-derived + scanned). ``scan`` + * asks the host to rescan its discovery roots first (headless installs have + * no Desktop to populate the cache). + */ + getChatWorkspaces: (profile = getManagementProfile(), scan = false) => + fetchJSON( + appendQueryParam( + appendProfileParam("/api/chat/workspaces", profile), + "scan", + scan ? "1" : undefined, + ), + ), getSessionLatestDescendant: (id: string, profile = getManagementProfile()) => fetchJSON( appendProfileParam( @@ -2038,6 +2052,31 @@ export interface DiskPressureStatus { used_percent?: number | null; } +export interface ChatWorkspaceProject { + id: string; + slug: string; + name: string; + primary_path: string | null; + archived: boolean; + folders: Array<{ path: string; label: string | null; is_primary: boolean }>; +} + +export interface ChatWorkspaceRepo { + root: string; + label: string; + sessions: number; + last_active: number; +} + +export interface ChatWorkspacesResponse { + projects: ChatWorkspaceProject[]; + repos: ChatWorkspaceRepo[]; + /** Where a fresh chat lands when no workspace is picked. */ + default_cwd: string; + home: string; + scan_enabled: boolean; +} + export interface SessionInfo { id: string; source: string | null; diff --git a/web/src/lib/chat-workspaces.test.ts b/web/src/lib/chat-workspaces.test.ts new file mode 100644 index 0000000000..bebbffc088 --- /dev/null +++ b/web/src/lib/chat-workspaces.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import type { ChatWorkspacesResponse } from "@/lib/api"; +import { abbreviateHomePath, workspaceOptions } from "@/lib/chat-workspaces"; + +const base: ChatWorkspacesResponse = { + projects: [], + repos: [], + default_cwd: "/home/u/.hermes", + home: "/home/u", + scan_enabled: true, +}; + +describe("workspaceOptions", () => { + it("lists project folders first, then repos by recency, deduped on path", () => { + const data: ChatWorkspacesResponse = { + ...base, + projects: [ + { + id: "p1", + slug: "site", + name: "Site", + primary_path: "/home/u/code/site", + archived: false, + folders: [ + { path: "/home/u/code/site", label: null, is_primary: true }, + { path: "/home/u/code/site-infra", label: "infra", is_primary: false }, + ], + }, + { + id: "p2", + slug: "old", + name: "Old", + primary_path: "/home/u/code/old", + archived: true, + folders: [{ path: "/home/u/code/old", label: null, is_primary: true }], + }, + ], + repos: [ + { root: "/home/u/code/site", label: "site", sessions: 3, last_active: 50 }, + { root: "/home/u/code/lib", label: "lib", sessions: 1, last_active: 10 }, + { root: "/home/u/code/app", label: "app", sessions: 2, last_active: 90 }, + ], + }; + + expect(workspaceOptions(data)).toEqual([ + { value: "/home/u/code/site", label: "Site · ~/code/site" }, + { value: "/home/u/code/site-infra", label: "Site · infra" }, + { value: "/home/u/code/app", label: "app ~/code/app" }, + { value: "/home/u/code/lib", label: "lib ~/code/lib" }, + ]); + }); +}); + +describe("abbreviateHomePath", () => { + it("collapses the home prefix only on a path boundary", () => { + expect(abbreviateHomePath("/home/u/code/x", "/home/u")).toBe("~/code/x"); + expect(abbreviateHomePath("/home/u", "/home/u/")).toBe("~"); + expect(abbreviateHomePath("/home/user2/code", "/home/u")).toBe("/home/user2/code"); + }); +}); diff --git a/web/src/lib/chat-workspaces.ts b/web/src/lib/chat-workspaces.ts new file mode 100644 index 0000000000..2adedc3d0a --- /dev/null +++ b/web/src/lib/chat-workspaces.ts @@ -0,0 +1,81 @@ +/** + * Workspace picker plumbing for the dashboard Chat tab: which host directory a + * FRESH chat starts in (`/api/pty?cwd=`), persisted per management profile so + * a phone remembers the repo it drives. + */ + +import type { ChatWorkspacesResponse } from "@/lib/api"; + +export interface WorkspaceOption { + value: string; + label: string; +} + +const STORAGE_PREFIX = "hermes-chat-workspace:"; + +export function workspaceStorageKey(profile?: string): string { + return `${STORAGE_PREFIX}${profile ?? ""}`; +} + +export function readStoredWorkspace(profile?: string): string { + try { + return localStorage.getItem(workspaceStorageKey(profile)) ?? ""; + } catch { + return ""; + } +} + +export function writeStoredWorkspace(profile: string | undefined, cwd: string): void { + try { + const key = workspaceStorageKey(profile); + if (cwd) localStorage.setItem(key, cwd); + else localStorage.removeItem(key); + } catch { + // Storage unavailable (private mode / quota): the pick still applies this session. + } +} + +/** `~`-abbreviate a host path for compact display. */ +export function abbreviateHomePath(path: string, home: string): string { + const h = home.replace(/[\\/]+$/, ""); + if (!h) return path; + if (path === h) return "~"; + const sep = path.startsWith(h + "/") ? "/" : path.startsWith(h + "\\") ? "\\" : null; + return sep ? `~${sep}${path.slice(h.length + 1)}` : path; +} + +/** + * Flatten the workspaces payload into picker rows: explicit project folders + * first (a multi-folder project contributes one row per folder), then + * discovered repos not already covered by a project folder, most recently + * active first. Deduped on the path. + */ +export function workspaceOptions(data: ChatWorkspacesResponse): WorkspaceOption[] { + const seen = new Set(); + const out: WorkspaceOption[] = []; + const push = (value: string, label: string) => { + if (!value || seen.has(value)) return; + seen.add(value); + out.push({ value, label }); + }; + for (const project of data.projects) { + if (project.archived) continue; + const folders = project.folders.length + ? project.folders + : project.primary_path + ? [{ path: project.primary_path, label: null, is_primary: true }] + : []; + for (const folder of folders) { + const suffix = + folders.length > 1 + ? ` · ${folder.label || abbreviateHomePath(folder.path, data.home)}` + : ""; + push(folder.path, `${project.name}${suffix}`); + } + } + const repos = [...data.repos].sort((a, b) => b.last_active - a.last_active); + for (const repo of repos) { + push(repo.root, `${repo.label} ${abbreviateHomePath(repo.root, data.home)}`); + } + return out; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 5cb6ce777d..2a97574dfa 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -35,6 +35,7 @@ import { ChatSessionList } from "@/components/ChatSessionList"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; +import { readStoredWorkspace, writeStoredWorkspace } from "@/lib/chat-workspaces"; import { latchChatActivation } from "@/lib/chat-activation"; import { copyTextToClipboard } from "@/lib/clipboard"; import { normalizeSessionTitle } from "@/lib/chat-title"; @@ -383,6 +384,27 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // management profile. Changing it remounts the terminal (key below / // effect dep) so the user explicitly starts a fresh scoped session. const { profile: scopedProfile } = useProfileScope(); + // Workspace a FRESH chat starts in (`/api/pty?cwd=`), persisted per + // management profile (a phone remembers the repo it drives). The connect + // effect reads storage directly, so changing the picker never respawns the + // live PTY: it applies on the next "New chat". + const [workspaceCwd, setWorkspaceCwdState] = useState(() => + readStoredWorkspace(scopedProfile), + ); + const setWorkspaceCwd = useCallback( + (next: string) => { + writeStoredWorkspace(scopedProfile, next); + setWorkspaceCwdState(next); + }, + [scopedProfile], + ); + // Profile switch: show that profile's remembered workspace (state, not an + // effect, so no cascading render). + const [workspaceProfile, setWorkspaceProfile] = useState(scopedProfile); + if (workspaceProfile !== scopedProfile) { + setWorkspaceProfile(scopedProfile); + setWorkspaceCwdState(readStoredWorkspace(scopedProfile)); + } const channel = useMemo( () => generateChannelId(`${resumeParam ?? ""}\0${scopedProfile}`), [resumeParam, scopedProfile], @@ -1228,6 +1250,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const params: Record = { channel }; if (resumeParam) params.resume = resumeParam; if (forceFresh) params.fresh = "1"; + // Picked workspace: only meaningful for a fresh chat (a resumed session + // keeps its own cwd); the server validates the directory exists. + const pickedWorkspace = resumeParam ? "" : readStoredWorkspace(scopedProfile); + if (pickedWorkspace) params.cwd = pickedWorkspace; // Keep-alive identity: reattach to this tab's living PTY across // refresh/transient drops. A forced-fresh start rotates the token so // the previous keep-alive PTY is not reattached (registry reaps it). @@ -1868,6 +1894,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { profile={scopedProfile} onPicked={closeMobilePanel} onNewChat={startFreshDashboardChat} + workspaceCwd={workspaceCwd} + onWorkspaceChange={setWorkspaceCwd} /> @@ -2077,6 +2105,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { activeSessionId={resumeParam} profile={scopedProfile} onNewChat={startFreshDashboardChat} + workspaceCwd={workspaceCwd} + onWorkspaceChange={setWorkspaceCwd} /> diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index bd695ce1b6..3b123023ed 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -148,6 +148,8 @@ The **Chat** tab embeds the full Hermes TUI (the same interface you get from `he **Session switcher (right rail):** the Chat tab carries its own ChatGPT-style conversation list in a thin right rail beside the terminal, so you can swap conversations without leaving the page. The rail stacks the model picker on top and the session list directly below it; the terminal takes up most of the screen. The list shows your most recent sessions for the active profile — title (falling back to a message preview), relative last-active time, message count, and the source channel for non-CLI sessions. Click any row to resume it in place (the terminal respawns with that conversation's history); the active session is highlighted. **New chat** starts a fresh session, and a refresh control re-pulls the list. The rail is read-only for switching — delete, rename, export, and bulk cleanup still live on the **Sessions** tab. On narrow screens it folds into a slide-over panel. +**Workspace picker:** a fresh chat starts wherever the dashboard process was launched — useless when you are driving Hermes from a phone and want it in `~/code/foo`. The rail's **workspace** selector lists the same directories the Desktop sidebar knows: your explicit projects (`hermes projects`) and every discovered git repository (session-derived plus the `desktop.repo_scan_roots` scan), most recently active first, with an **Other path…** entry for anything else. The choice is remembered per profile and applies to the next **New chat** (a resumed session keeps its own working directory); the rescan button re-walks the discovery roots on the host, so a repo you just cloned over SSH shows up without a restart. A path that no longer exists is refused with an error instead of silently starting in the launch directory. Backed by `GET /api/chat/workspaces` and the `cwd` parameter of the `/api/pty` WebSocket. + **Prerequisites:** - Node.js (same requirement as `hermes --tui`; the TUI bundle is built on first launch) @@ -465,6 +467,10 @@ Both collectors are fail-safe: any sampling error degrades the block to `{"pressure": "unknown"}` instead of failing the status endpoint. The numbers are coarse (whole MB, whole-percent) since `/api/status` is public. +### GET /api/chat/workspaces + +Directories a fresh Chat-tab session may start in: the profile's projects (with folders) and discovered git repositories (`root`, `label`, `sessions`, `last_active`), plus `default_cwd` (where a chat lands when nothing is picked) and `home`. `?scan=1` rescans `desktop.repo_scan_roots` on the host first. Pair with `/api/pty?cwd=`, which fails closed on a missing directory. + ### GET /api/sessions Returns the 20 most recent sessions with metadata (model, token counts, timestamps, preview).