From 4cdd1a3230b8135e0513727ac1616ca357c2402c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:26 -0500 Subject: [PATCH 01/19] feat(sessions): record git workspace metadata --- apps/desktop/src/hermes.ts | 7 ++- apps/desktop/src/types/hermes.ts | 41 +++++++++++++ hermes_state.py | 100 +++++++++++++++++++++++++++++-- tests/test_hermes_state.py | 75 +++++++++++++++++++++++ 4 files changed, 218 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 59bfceba06a..854ca35328d 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -99,6 +99,9 @@ export type { ProfileSetupCommand, ProfileSoul, ProfilesResponse, + ProjectFolder, + ProjectInfo, + ProjectsPayload, RpcEvent, SessionCreateResponse, SessionInfo, @@ -150,7 +153,9 @@ export async function listSessions( order: 'created' | 'recent' = 'recent' ): Promise { const result = await window.hermesDesktop.api({ - path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`, + path: + `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + + `&archived=${archived}&order=${order}`, timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS }) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 0f51f91a97c..68289dff86e 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -323,6 +323,16 @@ export interface SessionCreateResponse { export interface SessionInfo { archived?: boolean cwd?: null | string + /** Git branch checked out in {@link cwd} when the session started/resumed. + * The sidebar groups main-checkout sessions by this so feature-branch work + * doesn't collapse under a single directory-named "main" row. Null for + * non-git workspaces and sessions created before branch capture landed. */ + git_branch?: null | string + /** Git repo root that owns {@link cwd} — the authoritative project key, + * resolved server-side at cwd-set (and backfilled for history). The sidebar + * groups by this instead of probing git in the GUI. Null for non-git + * workspaces and not-yet-backfilled rows. */ + git_repo_root?: null | string ended_at: null | number id: string /** Original root id of a compression chain, when this entry is a projected @@ -335,6 +345,8 @@ export interface SessionInfo { message_count: number model: null | string output_tokens: number + /** Parent conversation when this row is a /branch fork. */ + parent_session_id?: null | string preview: null | string source: null | string started_at: number @@ -533,6 +545,35 @@ export interface ProfileSetupCommand { command: string } +// ── Projects ─────────────────────────────────────────────────────────────── +// A first-class, per-profile, human-named workspace spanning one or more +// folders. Mirrors hermes_cli/projects_db.Project.to_dict(). +export interface ProjectFolder { + path: string + label: null | string + is_primary: boolean + added_at: number +} + +export interface ProjectInfo { + id: string + slug: string + name: string + description: null | string + icon: null | string + color: null | string + board_slug: null | string + primary_path: null | string + archived: boolean + created_at: number + folders: ProjectFolder[] +} + +export interface ProjectsPayload { + projects: ProjectInfo[] + active_id: null | string +} + export interface ProfileSoul { content: string exists: boolean diff --git a/hermes_state.py b/hermes_state.py index 0c77d9c21dd..3d22c06ebee 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -33,6 +33,11 @@ def _delegate_from_json(col: str = "model_config") -> str: return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')" +def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]: + prefix = cwd_prefix.rstrip("/\\") or cwd_prefix + return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"] + + # A child session counts as a /branch (kept visible, never cascade-deleted) if # it carries the stable marker OR the legacy end_reason heuristic holds. _BRANCH_CHILD_SQL = ( @@ -539,6 +544,8 @@ CREATE TABLE IF NOT EXISTS sessions ( cache_write_tokens INTEGER DEFAULT 0, reasoning_tokens INTEGER DEFAULT 0, cwd TEXT, + git_branch TEXT, + git_repo_root TEXT, billing_provider TEXT, billing_base_url TEXT, billing_mode TEXT, @@ -1407,13 +1414,62 @@ class SessionDB: ) self._execute_write(_do) - def update_session_cwd(self, session_id: str, cwd: str) -> None: - """Persist the session working directory when a frontend knows it.""" + def update_session_cwd( + self, session_id: str, cwd: str, git_branch: str = None, git_repo_root: str = None + ) -> None: + """Persist the session working directory when a frontend knows it. + + ``git_branch`` records the git branch checked out in ``cwd`` at the time + the session started/resumed. The sidebar groups main-checkout sessions + by this so feature-branch work doesn't pile under a single "main" row + (the main checkout's *current* branch is transient and would + misattribute past sessions). + + ``git_repo_root`` records the git repo this cwd belongs to — the + authoritative project key. Resolving it here, at the lowest level, means + every surface reads the same membership instead of re-probing git in the + GUI over a partial page. Each field is only written when non-empty so a + probe failure never clobbers a previously-captured value. + """ if not session_id or not cwd: return + branch = (git_branch or "").strip() + repo_root = (git_repo_root or "").strip() + + sets = ["cwd = ?"] + params: List[Any] = [cwd] + if branch: + sets.append("git_branch = ?") + params.append(branch) + if repo_root: + sets.append("git_repo_root = ?") + params.append(repo_root) + params.append(session_id) + def _do(conn): - conn.execute("UPDATE sessions SET cwd = ? WHERE id = ?", (cwd, session_id)) + conn.execute(f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", params) + + self._execute_write(_do) + + def backfill_repo_roots(self, cwd_to_root: Dict[str, str]) -> None: + """Persist resolved git repo roots for cwds that don't have one yet. + + Backfills history so projects light up for sessions created before the + column existed, without clobbering an already-recorded root. Only + non-empty roots are written (a non-git cwd stays NULL). + """ + pairs = [(root, cwd) for cwd, root in cwd_to_root.items() if root and cwd] + if not pairs: + return + + def _do(conn): + for root, cwd in pairs: + conn.execute( + "UPDATE sessions SET git_repo_root = ? " + "WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''", + (root, cwd), + ) self._execute_write(_do) # ────────────────────────────────────────────────────────────────────── @@ -2102,10 +2158,37 @@ class SessionDB: current = row["id"] return current + def distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]: + """Distinct non-empty session cwds with usage stats, for repo discovery. + + Aggregates across ALL session history (not a single page), so the desktop + can surface every git repo the user has worked in — not just the repos + that happen to be in the currently-loaded recents. Children/branches + count: a worktree session is still a real workspace signal. + """ + where = "cwd IS NOT NULL AND TRIM(cwd) != ''" + if not include_archived: + where += " AND archived = 0" + with self._lock: + rows = self._conn.execute( + "SELECT cwd AS cwd, COUNT(*) AS sessions, " + "MAX(COALESCE(ended_at, started_at, 0)) AS last_active " + f"FROM sessions WHERE {where} GROUP BY cwd" + ).fetchall() + return [ + { + "cwd": r["cwd"], + "sessions": int(r["sessions"] or 0), + "last_active": float(r["last_active"] or 0), + } + for r in rows + ] + def list_sessions_rich( self, source: str = None, exclude_sources: List[str] = None, + cwd_prefix: str = None, limit: int = 20, offset: int = 0, include_children: bool = False, @@ -2171,6 +2254,10 @@ class SessionDB: placeholders = ",".join("?" for _ in exclude_sources) where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(exclude_sources) + if cwd_prefix: + clause, clause_params = _cwd_prefix_clause(cwd_prefix) + where_clauses.append(clause) + params.extend(clause_params) if min_message_count > 0: where_clauses.append("s.message_count >= ?") params.append(min_message_count) @@ -2330,7 +2417,7 @@ class SessionDB: for key in ( "id", "ended_at", "end_reason", "message_count", "tool_call_count", "title", "last_active", "preview", - "model", "system_prompt", "cwd", + "model", "system_prompt", "cwd", "git_branch", "git_repo_root", ): if key in tip_row: merged[key] = tip_row[key] @@ -3874,6 +3961,7 @@ class SessionDB: def session_count( self, source: str = None, + cwd_prefix: str = None, min_message_count: int = 0, include_archived: bool = False, archived_only: bool = False, @@ -3910,6 +3998,10 @@ class SessionDB: placeholders = ",".join("?" for _ in exclude_sources) where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(exclude_sources) + if cwd_prefix: + clause, clause_params = _cwd_prefix_clause(cwd_prefix) + where_clauses.append(clause) + params.extend(clause_params) if min_message_count > 0: where_clauses.append("s.message_count >= ?") params.append(min_message_count) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 1d727132a8c..e75aa467c2d 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -96,6 +96,66 @@ class TestSessionLifecycle: def test_get_nonexistent_session(self, db): assert db.get_session("nonexistent") is None + def test_update_session_cwd_persists_git_branch(self, db): + db.create_session(session_id="s1", source="cli") + db.update_session_cwd("s1", "/work/repo", git_branch="pets-feature") + + session = db.get_session("s1") + assert session["cwd"] == "/work/repo" + assert session["git_branch"] == "pets-feature" + + def test_update_session_cwd_empty_branch_does_not_clobber(self, db): + """A failed branch probe (empty string) must not wipe a branch we + already captured — only the cwd updates.""" + db.create_session(session_id="s1", source="cli") + db.update_session_cwd("s1", "/work/repo", git_branch="main") + db.update_session_cwd("s1", "/work/repo", git_branch="") + + session = db.get_session("s1") + assert session["git_branch"] == "main" + + def test_update_session_cwd_without_branch_arg(self, db): + """Back-compat: callers that pass only (id, cwd) still work.""" + db.create_session(session_id="s1", source="cli") + db.update_session_cwd("s1", "/work/repo") + + session = db.get_session("s1") + assert session["cwd"] == "/work/repo" + assert session["git_branch"] is None + + def test_update_session_cwd_persists_git_repo_root(self, db): + db.create_session(session_id="s1", source="cli") + db.update_session_cwd("s1", "/work/repo/src", git_repo_root="/work/repo") + + assert db.get_session("s1")["git_repo_root"] == "/work/repo" + + def test_update_session_cwd_empty_repo_root_does_not_clobber(self, db): + db.create_session(session_id="s1", source="cli") + db.update_session_cwd("s1", "/work/repo", git_repo_root="/work/repo") + db.update_session_cwd("s1", "/work/repo", git_repo_root="") + + assert db.get_session("s1")["git_repo_root"] == "/work/repo" + + def test_distinct_session_cwds_aggregates_history(self, db): + db.create_session("s1", "cli", cwd="/repo") + db.create_session("s2", "cli", cwd="/repo") + db.create_session("s3", "cli", cwd="/other") + db.create_session("s4", "cli") # no cwd — excluded + + rows = {r["cwd"]: r["sessions"] for r in db.distinct_session_cwds()} + assert rows == {"/repo": 2, "/other": 1} + + def test_backfill_repo_roots_fills_only_empty(self, db): + db.create_session("s1", "cli", cwd="/repo/a") + db.create_session("s2", "cli", cwd="/repo/b") + db.update_session_cwd("s2", "/repo/b", git_repo_root="/already") + + db.backfill_repo_roots({"/repo/a": "/repo", "/repo/b": "/repo"}) + + assert db.get_session("s1")["git_repo_root"] == "/repo" + # Pre-existing root is preserved, not clobbered. + assert db.get_session("s2")["git_repo_root"] == "/already" + def test_end_session(self, db): db.create_session(session_id="s1", source="cli") db.end_session("s1", end_reason="user_exit") @@ -1526,6 +1586,13 @@ class TestCounts: assert db.session_count(source="cli") == 2 assert db.session_count(source="telegram") == 1 + def test_session_count_by_cwd_prefix(self, db): + db.create_session("s1", "cli", cwd="/repo") + db.create_session("s2", "cli", cwd="/repo-wt-feature") + db.create_session("s3", "cli", cwd="/repo/subdir") + + assert db.session_count(cwd_prefix="/repo") == 2 + def test_message_count_total(self, db): assert db.message_count() == 0 db.create_session(session_id="s1", source="cli") @@ -3057,6 +3124,14 @@ class TestListSessionsRich: assert len(sessions) == 1 assert sessions[0]["id"] == "s1" + def test_rich_list_cwd_prefix_filter(self, db): + db.create_session("s1", "cli", cwd="/repo") + db.create_session("s2", "cli", cwd="/repo/subdir") + db.create_session("s3", "cli", cwd="/repo-wt-feature") + + sessions = db.list_sessions_rich(cwd_prefix="/repo") + assert [session["id"] for session in sessions] == ["s2", "s1"] + def test_preview_newlines_collapsed(self, db): db.create_session("s1", "cli") db.append_message("s1", "user", "Line one\nLine two\nLine three") From 8a45ce2dd4005700bd52f82881ab6c7999a767e3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:26 -0500 Subject: [PATCH 02/19] feat(projects): add per-profile project store --- hermes_cli/projects_cmd.py | 335 ++++++++++++ hermes_cli/projects_db.py | 727 ++++++++++++++++++++++++++ hermes_cli/sqlite_util.py | 49 ++ tests/hermes_cli/test_projects_cli.py | 84 +++ tests/hermes_cli/test_projects_db.py | 174 ++++++ 5 files changed, 1369 insertions(+) create mode 100644 hermes_cli/projects_cmd.py create mode 100644 hermes_cli/projects_db.py create mode 100644 hermes_cli/sqlite_util.py create mode 100644 tests/hermes_cli/test_projects_cli.py create mode 100644 tests/hermes_cli/test_projects_db.py diff --git a/hermes_cli/projects_cmd.py b/hermes_cli/projects_cmd.py new file mode 100644 index 00000000000..4e2655b5cc2 --- /dev/null +++ b/hermes_cli/projects_cmd.py @@ -0,0 +1,335 @@ +"""``hermes project`` CLI — manage first-class, multi-folder Projects. + +A Project is a human-named workspace spanning one or more folders, with one +designated primary repo. Projects anchor desktop session grouping and (when +bound to a kanban board) give kanban tasks a deterministic worktree + branch +convention. State lives in the per-profile ``$HERMES_HOME/projects.db`` store +(see :mod:`hermes_cli.projects_db`). + +This is a footprint-ladder rung-2 capability: a CLI command + gateway RPC, +with zero model-tool schema cost. +""" + +from __future__ import annotations + +import argparse +import functools +import sys + +from hermes_cli import projects_db as pdb + + +def build_parser( + parent_subparsers: argparse._SubParsersAction, +) -> argparse.ArgumentParser: + """Attach the ``project`` subcommand tree. Returns the top parser.""" + parser = parent_subparsers.add_parser( + "project", + help="Manage projects (named, multi-folder workspaces)", + description=( + "Projects are human-named workspaces that can span multiple " + "folders / repos. They anchor desktop session grouping and, when " + "bound to a kanban board, give tasks a deterministic worktree + " + "branch convention. State is per-profile." + ), + ) + sub = parser.add_subparsers(dest="project_action") + + p_create = sub.add_parser("create", help="Create a new project") + p_create.add_argument("name", help="Human name, e.g. 'Hermes Agent'") + p_create.add_argument( + "folders", nargs="*", help="Folder paths to include (first = primary)" + ) + p_create.add_argument("--slug", default=None, help="Explicit slug override") + p_create.add_argument( + "--primary", default=None, metavar="PATH", help="Primary repo path" + ) + p_create.add_argument("--description", default=None) + p_create.add_argument("--icon", default=None) + p_create.add_argument("--color", default=None) + p_create.add_argument( + "--board", default=None, metavar="SLUG", help="Bind a kanban board" + ) + p_create.add_argument( + "--use", action="store_true", help="Set as the active project" + ) + + p_list = sub.add_parser("list", aliases=["ls"], help="List projects") + p_list.add_argument( + "--all", action="store_true", dest="include_archived", + help="Include archived projects", + ) + + p_show = sub.add_parser("show", help="Show a project's details") + p_show.add_argument("project", help="Project id or slug") + + p_add = sub.add_parser("add-folder", help="Add a folder to a project") + p_add.add_argument("project", help="Project id or slug") + p_add.add_argument("path", help="Folder path") + p_add.add_argument("--label", default=None) + p_add.add_argument( + "--primary", action="store_true", help="Mark as primary repo" + ) + + p_rm = sub.add_parser("remove-folder", help="Remove a folder from a project") + p_rm.add_argument("project", help="Project id or slug") + p_rm.add_argument("path", help="Folder path") + + p_rename = sub.add_parser("rename", help="Rename a project") + p_rename.add_argument("project", help="Project id or slug") + p_rename.add_argument("name", help="New name") + + p_primary = sub.add_parser("set-primary", help="Set the primary folder") + p_primary.add_argument("project", help="Project id or slug") + p_primary.add_argument("path", help="Folder path (must already be in project)") + + p_use = sub.add_parser("use", help="Set the active project") + p_use.add_argument( + "project", nargs="?", default=None, + help="Project id or slug (omit to clear)", + ) + + p_archive = sub.add_parser("archive", help="Archive a project") + p_archive.add_argument("project", help="Project id or slug") + + p_restore = sub.add_parser("restore", help="Restore an archived project") + p_restore.add_argument("project", help="Project id or slug") + + p_bind = sub.add_parser("bind-board", help="Bind a kanban board to a project") + p_bind.add_argument("project", help="Project id or slug") + p_bind.add_argument( + "board", nargs="?", default="", help="Board slug (omit to unbind)" + ) + + parser.set_defaults(_project_parser=parser) + return parser + + +def projects_command(args: argparse.Namespace) -> int: + """Entry point from ``hermes project …`` argparse dispatch.""" + action = getattr(args, "project_action", None) + if not action: + parser = getattr(args, "_project_parser", None) + if parser is not None: + parser.print_help() + else: + print( + "usage: hermes project [options]\n" + "Run 'hermes project --help' for the full list.", + file=sys.stderr, + ) + return 0 + + handlers = { + "create": _cmd_create, + "list": _cmd_list, + "ls": _cmd_list, + "show": _cmd_show, + "add-folder": _cmd_add_folder, + "remove-folder": _cmd_remove_folder, + "rename": _cmd_rename, + "set-primary": _cmd_set_primary, + "use": _cmd_use, + "archive": _cmd_archive, + "restore": _cmd_restore, + "bind-board": _cmd_bind_board, + } + handler = handlers.get(action) + if handler is None: + print(f"Unknown project action: {action}", file=sys.stderr) + return 1 + return handler(args) + + +def _resolve(conn, ident: str): + proj = pdb.get_project(conn, ident) + if proj is None: + print(f"project: no such project: {ident}", file=sys.stderr) + return proj + + +def _with_project(fn): + """Open the DB, resolve ``args.project``, and run ``fn(args, conn, proj)``. + + Collapses the connect / resolve / not-found(1) / bad-arg(2) boilerplate every + project-scoped subcommand repeated. + """ + + @functools.wraps(fn) + def wrapper(args: argparse.Namespace) -> int: + with pdb.connect_closing() as conn: + proj = _resolve(conn, args.project) + if proj is None: + return 1 + try: + return fn(args, conn, proj) + except ValueError as exc: + print(f"project: {exc}", file=sys.stderr) + return 2 + + return wrapper + + +def _print_project(proj) -> None: + flags = " (archived)" if proj.archived else "" + print(f"{proj.slug} [{proj.id}]{flags}") + print(f" name: {proj.name}") + if proj.description: + print(f" about: {proj.description}") + if proj.board_slug: + print(f" board: {proj.board_slug}") + if proj.primary_path: + print(f" primary: {proj.primary_path}") + if proj.folders: + print(" folders:") + for f in proj.folders: + mark = " *" if f.is_primary else " " + label = f" ({f.label})" if f.label else "" + print(f" {mark} {f.path}{label}") + + +def _cmd_create(args: argparse.Namespace) -> int: + try: + with pdb.connect_closing() as conn: + pid = pdb.create_project( + conn, + name=args.name, + slug=args.slug, + folders=args.folders, + primary_path=args.primary, + description=args.description, + icon=args.icon, + color=args.color, + board_slug=args.board, + ) + if args.use: + pdb.set_active(conn, pid) + proj = pdb.get_project(conn, pid) + except ValueError as exc: + print(f"project: {exc}", file=sys.stderr) + return 2 + if proj is None: + print("project: vanished after create", file=sys.stderr) + return 2 + print(f"Created project {proj.slug} ({pid})") + _print_project(proj) + return 0 + + +def _cmd_list(args: argparse.Namespace) -> int: + with pdb.connect_closing() as conn: + active = pdb.get_active_id(conn) + projs = pdb.list_projects( + conn, include_archived=getattr(args, "include_archived", False) + ) + if not projs: + print("No projects yet. Create one with `hermes project create `.") + return 0 + for p in projs: + marker = "*" if p.id == active else " " + flags = " (archived)" if p.archived else "" + nfolders = len(p.folders) + print(f"{marker} {p.slug:<24} {p.name}{flags} [{nfolders} folder(s)]") + return 0 + + +@_with_project +def _cmd_show(args, conn, proj) -> int: + _print_project(proj) + return 0 + + +@_with_project +def _cmd_add_folder(args, conn, proj) -> int: + path = pdb.add_folder(conn, proj.id, args.path, label=args.label, is_primary=args.primary) + print(f"Added {path} to {proj.slug}") + return 0 + + +@_with_project +def _cmd_remove_folder(args, conn, proj) -> int: + if not pdb.remove_folder(conn, proj.id, args.path): + print(f"project: folder not in project: {args.path}", file=sys.stderr) + return 1 + print(f"Removed {args.path} from {proj.slug}") + return 0 + + +@_with_project +def _cmd_rename(args, conn, proj) -> int: + pdb.update_project(conn, proj.id, name=args.name) + print(f"Renamed {proj.slug} -> {args.name}") + return 0 + + +@_with_project +def _cmd_set_primary(args, conn, proj) -> int: + if not pdb.set_primary(conn, proj.id, args.path): + print( + f"project: '{args.path}' is not a folder of {proj.slug}; " + f"add it first with `hermes project add-folder`.", + file=sys.stderr, + ) + return 1 + print(f"Set primary of {proj.slug} -> {args.path}") + return 0 + + +def _cmd_use(args: argparse.Namespace) -> int: + with pdb.connect_closing() as conn: + if not args.project: + pdb.set_active(conn, None) + print("Cleared active project") + return 0 + proj = _resolve(conn, args.project) + if proj is None: + return 1 + pdb.set_active(conn, proj.id) + print(f"Active project: {proj.slug}") + return 0 + + +@_with_project +def _cmd_archive(args, conn, proj) -> int: + pdb.archive_project(conn, proj.id) + print(f"Archived {proj.slug}") + return 0 + + +@_with_project +def _cmd_restore(args, conn, proj) -> int: + pdb.restore_project(conn, proj.id) + print(f"Restored {proj.slug}") + return 0 + + +@_with_project +def _cmd_bind_board(args, conn, proj) -> int: + pdb.update_project(conn, proj.id, board_slug=args.board) + if args.board.strip(): + print(f"Bound {proj.slug} -> board {args.board}") + _sync_board_default_workdir(proj, args.board) + else: + print(f"Unbound board from {proj.slug}") + return 0 + + +def _sync_board_default_workdir(proj, board_slug: str) -> None: + """Best-effort: point the bound board's default_workdir at the primary repo. + + Keeps kanban task worktrees anchored to the project's repo. Failures here + are non-fatal — the binding itself already succeeded. + """ + if not proj.primary_path: + return + try: + from hermes_cli import kanban_db as kb + + slug = kb._normalize_board_slug(board_slug) + if not slug: + return + if slug != kb.DEFAULT_BOARD and not kb.board_exists(slug): + return + kb.write_board_metadata(slug, default_workdir=proj.primary_path) + except Exception: + pass diff --git a/hermes_cli/projects_db.py b/hermes_cli/projects_db.py new file mode 100644 index 00000000000..0512a58326c --- /dev/null +++ b/hermes_cli/projects_db.py @@ -0,0 +1,727 @@ +"""Per-profile first-class Project store. + +A **Project** is a human-named, multi-folder workspace. Unlike the desktop's +old inferred "workspaces" (derived from each session's ``cwd`` + a git probe) +and unlike kanban's self-generated worktrees, a Project is an explicit, +persisted entity the user creates and names. It anchors: + +- **Desktop session grouping** — a session belongs to a project when its + ``cwd`` lives under one of the project's folders (longest-prefix match). +- **Kanban task worktrees** — a task linked to a project creates its worktree + under the project's primary repo with a deterministic branch name, instead + of the random ``wt/`` fallback. + +Scope: **per-profile**, stored at ``$HERMES_HOME/projects.db`` (resolved via +``get_hermes_home()``), mirroring sessions / config / cron. This deliberately +differs from kanban, whose board DB is root-anchored and shared across +profiles. A Project may *bind* a kanban board (``board_slug``) so the two +systems agree on the repo + branch convention without merging their stores. + +The schema is intentionally small and additive: column additions go through +:func:`_add_column_if_missing` so opening an old DB is always safe. +""" + +from __future__ import annotations + +import contextlib +import os +import re +import secrets +import sqlite3 +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, List, Optional + +from hermes_cli.sqlite_util import add_column_if_missing as _add_column_if_missing, write_txn +from hermes_constants import get_hermes_home + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + + +def projects_db_path() -> Path: + """The per-profile projects DB path (``$HERMES_HOME/projects.db``). + + Profile-aware: ``get_hermes_home()`` already points at the active profile's + home. Tests pass an explicit ``db_path`` to :func:`connect`. + """ + return get_hermes_home() / "projects.db" + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + icon TEXT, + color TEXT, + board_slug TEXT, + primary_path TEXT, + created_at INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS project_folders ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + path TEXT NOT NULL, + label TEXT, + is_primary INTEGER NOT NULL DEFAULT 0, + added_at INTEGER NOT NULL, + PRIMARY KEY (project_id, path) +); + +CREATE INDEX IF NOT EXISTS idx_project_folders_path + ON project_folders(path); + +CREATE TABLE IF NOT EXISTS project_meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +-- Git repos found by scanning the filesystem (desktop "repo-first" discovery). +-- Cached here so the overview is instant after the first scan instead of +-- re-walking the disk every time the Projects view opens. +CREATE TABLE IF NOT EXISTS discovered_repos ( + root TEXT PRIMARY KEY, + label TEXT, + last_seen INTEGER NOT NULL +); +""" + + +# --------------------------------------------------------------------------- +# Slug + id helpers +# --------------------------------------------------------------------------- + +# Lowercase alphanumerics, hyphens, underscores; 1-64 chars; no leading +# separator. Strict enough to stop traversal and path separators, loose enough +# for kebab-case names like ``hermes-agent``. Display formatting (spaces, +# emoji, capitalisation) lives in ``name``; the slug is just a stable handle. +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$") + + +def _slugify(name: str) -> str: + """Derive a slug candidate from a human name (best-effort).""" + s = str(name or "").strip().lower() + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-_") + s = s[:64].strip("-_") + return s or "project" + + +def normalize_slug(slug: Optional[str]) -> Optional[str]: + """Lowercase + strip a slug; validate; return ``None`` for empty.""" + if slug is None: + return None + s = str(slug).strip().lower() + if not s: + return None + if not _SLUG_RE.match(s): + raise ValueError( + f"invalid project slug {slug!r}: must be 1-64 chars, lowercase " + f"alphanumerics / hyphens / underscores, not starting with " + f"'-' or '_'" + ) + return s + + +def _new_project_id() -> str: + return "p_" + secrets.token_hex(4) + + +def _now() -> int: + return int(time.time()) + + +def _normalize_path(path: str) -> str: + """Absolute, user-expanded, separator-normalized path (no trailing sep).""" + p = os.path.abspath(os.path.expanduser(str(path).strip())) + return p.rstrip("/\\") or p + + +# --------------------------------------------------------------------------- +# Connection management +# --------------------------------------------------------------------------- + +_INITIALIZED_PATHS: set[str] = set() + + +def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: + """Open (and initialize if needed) the per-profile projects DB. + + WAL with DELETE fallback for network filesystems (shared helper from + ``hermes_state``). Schema init is idempotent (``CREATE TABLE IF NOT + EXISTS`` + additive migrations) and cached per-path per-process. + """ + path = db_path if db_path is not None else projects_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + resolved = str(path.resolve()) + conn = sqlite3.connect(str(path)) + try: + conn.row_factory = sqlite3.Row + from hermes_state import apply_wal_with_fallback + + apply_wal_with_fallback(conn, db_label="projects.db") + conn.execute("PRAGMA foreign_keys=ON") + if resolved not in _INITIALIZED_PATHS: + conn.executescript(SCHEMA_SQL) + _migrate_add_optional_columns(conn) + _INITIALIZED_PATHS.add(resolved) + except Exception: + conn.close() + raise + return conn + + +@contextlib.contextmanager +def connect_closing(db_path: Optional[Path] = None): + """Open a projects DB connection and guarantee it is closed on exit. + + sqlite3's connection context manager only commits/rollbacks; it does NOT + close the file descriptor. Long-lived processes (gateway, dashboard) route + many project operations through ``connect()``; without closing, FDs to + ``projects.db`` accumulate. Mirrors ``kanban_db.connect_closing``. + """ + conn = connect(db_path=db_path) + try: + yield conn + finally: + try: + conn.close() + except Exception: + pass + + +# TEXT columns added to `projects` after v1; re-applied idempotently on every +# open so a legacy DB upgrades in place. +_OPTIONAL_PROJECT_COLUMNS = ("board_slug", "primary_path", "icon", "color") + + +def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: + """Add columns introduced after v1 to legacy DBs (safe on every open).""" + cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")} + for col in _OPTIONAL_PROJECT_COLUMNS: + if col not in cols: + _add_column_if_missing(conn, "projects", col, f"{col} TEXT") + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class ProjectFolder: + path: str + label: Optional[str] = None + is_primary: bool = False + added_at: int = 0 + + def to_dict(self) -> dict: + return { + "path": self.path, + "label": self.label, + "is_primary": bool(self.is_primary), + "added_at": self.added_at, + } + + +@dataclass +class Project: + id: str + slug: str + name: str + created_at: int + description: Optional[str] = None + icon: Optional[str] = None + color: Optional[str] = None + board_slug: Optional[str] = None + primary_path: Optional[str] = None + archived: bool = False + folders: List[ProjectFolder] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "id": self.id, + "slug": self.slug, + "name": self.name, + "description": self.description, + "icon": self.icon, + "color": self.color, + "board_slug": self.board_slug, + "primary_path": self.primary_path, + "archived": bool(self.archived), + "created_at": self.created_at, + "folders": [f.to_dict() for f in self.folders], + } + + +def _project_from_row(row: sqlite3.Row) -> Project: + keys = row.keys() + return Project( + id=row["id"], + slug=row["slug"], + name=row["name"], + created_at=row["created_at"], + description=row["description"] if "description" in keys else None, + icon=row["icon"] if "icon" in keys else None, + color=row["color"] if "color" in keys else None, + board_slug=row["board_slug"] if "board_slug" in keys else None, + primary_path=row["primary_path"] if "primary_path" in keys else None, + archived=bool(row["archived"]) if "archived" in keys else False, + ) + + +def _load_folders(conn: sqlite3.Connection, project_id: str) -> List[ProjectFolder]: + rows = conn.execute( + "SELECT path, label, is_primary, added_at FROM project_folders " + "WHERE project_id = ? ORDER BY is_primary DESC, added_at ASC", + (project_id,), + ).fetchall() + return [ + ProjectFolder( + path=r["path"], + label=r["label"], + is_primary=bool(r["is_primary"]), + added_at=r["added_at"], + ) + for r in rows + ] + + +def _attach_folders(conn: sqlite3.Connection, project: Project) -> Project: + project.folders = _load_folders(conn, project.id) + return project + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +def _unique_slug(conn: sqlite3.Connection, candidate: str) -> str: + """Return ``candidate`` or ``candidate-2``, ``-3`` ... if taken.""" + base = candidate + n = 1 + slug = base + while conn.execute( + "SELECT 1 FROM projects WHERE slug = ?", (slug,) + ).fetchone() is not None: + n += 1 + suffix = f"-{n}" + slug = (base[: 64 - len(suffix)]).rstrip("-_") + suffix + return slug + + +def create_project( + conn: sqlite3.Connection, + *, + name: str, + slug: Optional[str] = None, + folders: Optional[Iterable[str]] = None, + primary_path: Optional[str] = None, + description: Optional[str] = None, + icon: Optional[str] = None, + color: Optional[str] = None, + board_slug: Optional[str] = None, +) -> str: + """Create a project and return its id. + + ``folders`` are normalized to absolute paths. If ``primary_path`` is given + it is added to the folder set (if not already present) and marked primary; + otherwise the first folder becomes primary. + """ + name = str(name or "").strip() + if not name: + raise ValueError("project name must not be empty") + + slug_candidate = normalize_slug(slug) if slug else _slugify(name) + pid = _new_project_id() + now = _now() + + folder_paths: List[str] = [] + for f in folders or []: + norm = _normalize_path(f) + if norm and norm not in folder_paths: + folder_paths.append(norm) + + primary = _normalize_path(primary_path) if primary_path else None + if primary and primary not in folder_paths: + folder_paths.insert(0, primary) + if primary is None and folder_paths: + primary = folder_paths[0] + + with write_txn(conn): + unique = _unique_slug(conn, slug_candidate) + conn.execute( + "INSERT INTO projects " + "(id, slug, name, description, icon, color, board_slug, " + " primary_path, created_at, archived) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", + ( + pid, + unique, + name, + description, + icon, + color, + normalize_slug(board_slug) if board_slug else None, + primary, + now, + ), + ) + for path in folder_paths: + conn.execute( + "INSERT INTO project_folders " + "(project_id, path, label, is_primary, added_at) " + "VALUES (?, ?, ?, ?, ?)", + (pid, path, None, 1 if path == primary else 0, now), + ) + return pid + + +def list_projects( + conn: sqlite3.Connection, *, include_archived: bool = False +) -> List[Project]: + sql = "SELECT * FROM projects" + if not include_archived: + sql += " WHERE archived = 0" + sql += " ORDER BY created_at ASC" + rows = conn.execute(sql).fetchall() + return [_attach_folders(conn, _project_from_row(r)) for r in rows] + + +def get_project( + conn: sqlite3.Connection, id_or_slug: str +) -> Optional[Project]: + """Look up a project by id first, then by slug.""" + row = conn.execute( + "SELECT * FROM projects WHERE id = ?", (id_or_slug,) + ).fetchone() + if row is None: + row = conn.execute( + "SELECT * FROM projects WHERE slug = ?", (str(id_or_slug).lower(),) + ).fetchone() + if row is None: + return None + return _attach_folders(conn, _project_from_row(row)) + + +def update_project( + conn: sqlite3.Connection, + project_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + icon: Optional[str] = None, + color: Optional[str] = None, + board_slug: Optional[str] = None, +) -> bool: + """Patch top-level project fields. Only provided fields change. + + ``icon``, ``color``, and ``board_slug`` accept an empty string to clear + (store NULL) — passing ``None`` leaves the field untouched, so callers that + want to clear must send ``""``. + """ + sets: List[str] = [] + params: List[object] = [] + if name is not None: + n = str(name).strip() + if not n: + raise ValueError("project name must not be empty") + sets.append("name = ?") + params.append(n) + if description is not None: + sets.append("description = ?") + params.append(description) + if icon is not None: + sets.append("icon = ?") + params.append(icon or None) + if color is not None: + sets.append("color = ?") + params.append(color or None) + if board_slug is not None: + sets.append("board_slug = ?") + params.append(normalize_slug(board_slug) if board_slug.strip() else None) + if not sets: + return False + params.append(project_id) + with write_txn(conn): + cur = conn.execute( + f"UPDATE projects SET {', '.join(sets)} WHERE id = ?", params + ) + return cur.rowcount > 0 + + +def add_folder( + conn: sqlite3.Connection, + project_id: str, + path: str, + *, + label: Optional[str] = None, + is_primary: bool = False, +) -> str: + """Add a folder to a project. Returns the normalized path. + + When ``is_primary`` is set, the folder becomes the project's primary repo + (the previous primary is demoted, and ``projects.primary_path`` updates). + """ + norm = _normalize_path(path) + if not norm: + raise ValueError("folder path must not be empty") + if get_project(conn, project_id) is None: + raise ValueError(f"no such project: {project_id}") + now = _now() + with write_txn(conn): + conn.execute( + "INSERT OR IGNORE INTO project_folders " + "(project_id, path, label, is_primary, added_at) " + "VALUES (?, ?, ?, 0, ?)", + (project_id, norm, label, now), + ) + if label is not None: + conn.execute( + "UPDATE project_folders SET label = ? " + "WHERE project_id = ? AND path = ?", + (label, project_id, norm), + ) + if is_primary: + _set_primary_locked(conn, project_id, norm) + else: + # First folder of an empty project becomes primary implicitly. + existing_primary = conn.execute( + "SELECT 1 FROM project_folders " + "WHERE project_id = ? AND is_primary = 1", + (project_id,), + ).fetchone() + if existing_primary is None: + _set_primary_locked(conn, project_id, norm) + return norm + + +def remove_folder(conn: sqlite3.Connection, project_id: str, path: str) -> bool: + """Remove a folder from a project. Repoints primary if it was primary.""" + norm = _normalize_path(path) + with write_txn(conn): + was_primary = conn.execute( + "SELECT is_primary FROM project_folders " + "WHERE project_id = ? AND path = ?", + (project_id, norm), + ).fetchone() + cur = conn.execute( + "DELETE FROM project_folders WHERE project_id = ? AND path = ?", + (project_id, norm), + ) + if was_primary is not None and was_primary["is_primary"]: + nxt = conn.execute( + "SELECT path FROM project_folders WHERE project_id = ? " + "ORDER BY added_at ASC LIMIT 1", + (project_id,), + ).fetchone() + new_primary = nxt["path"] if nxt else None + if new_primary: + _set_primary_locked(conn, project_id, new_primary) + else: + conn.execute( + "UPDATE projects SET primary_path = NULL WHERE id = ?", + (project_id,), + ) + return cur.rowcount > 0 + + +def _set_primary_locked( + conn: sqlite3.Connection, project_id: str, path: str +) -> None: + """Set the primary folder (caller already holds a write txn).""" + conn.execute( + "UPDATE project_folders SET is_primary = 0 WHERE project_id = ?", + (project_id,), + ) + conn.execute( + "UPDATE project_folders SET is_primary = 1 " + "WHERE project_id = ? AND path = ?", + (project_id, path), + ) + conn.execute( + "UPDATE projects SET primary_path = ? WHERE id = ?", + (path, project_id), + ) + + +def set_primary(conn: sqlite3.Connection, project_id: str, path: str) -> bool: + norm = _normalize_path(path) + with write_txn(conn): + exists = conn.execute( + "SELECT 1 FROM project_folders WHERE project_id = ? AND path = ?", + (project_id, norm), + ).fetchone() + if exists is None: + return False + _set_primary_locked(conn, project_id, norm) + return True + + +def archive_project(conn: sqlite3.Connection, project_id: str) -> bool: + with write_txn(conn): + cur = conn.execute( + "UPDATE projects SET archived = 1 WHERE id = ?", (project_id,) + ) + return cur.rowcount > 0 + + +def restore_project(conn: sqlite3.Connection, project_id: str) -> bool: + with write_txn(conn): + cur = conn.execute( + "UPDATE projects SET archived = 0 WHERE id = ?", (project_id,) + ) + return cur.rowcount > 0 + + +def delete_project(conn: sqlite3.Connection, project_id: str) -> bool: + """Hard-delete a project and its folders (cascade).""" + with write_txn(conn): + cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) + return cur.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Active-project pointer (project_meta KV) +# --------------------------------------------------------------------------- + + +_ACTIVE_META_KEY = "active_id" + + +def set_active(conn: sqlite3.Connection, project_id: Optional[str]) -> None: + """Set (or clear, when ``None``) the active project pointer.""" + with write_txn(conn): + if project_id is None: + conn.execute("DELETE FROM project_meta WHERE key = ?", (_ACTIVE_META_KEY,)) + else: + conn.execute( + "INSERT INTO project_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (_ACTIVE_META_KEY, project_id), + ) + + +def get_active_id(conn: sqlite3.Connection) -> Optional[str]: + row = conn.execute( + "SELECT value FROM project_meta WHERE key = ?", (_ACTIVE_META_KEY,) + ).fetchone() + return row["value"] if row else None + + +# --------------------------------------------------------------------------- +# Discovered repos (filesystem scan cache) +# --------------------------------------------------------------------------- + + +def record_discovered_repos( + conn: sqlite3.Connection, + repos: Iterable[tuple[str, Optional[str]]], + *, + replace: bool = False, +) -> int: + """Persist scanned git repo roots into the cache. + + ``repos`` is an iterable of ``(root, label)``. Roots are normalized; the + label falls back to the basename. Returns the number of rows written. + + When ``replace`` is true, this is the authoritative result of a fresh disk + scan: delete stale rows first so old eval/worktree noise disappears instead + of living forever in the cache. + """ + now = _now() + rows = [] + for root, label in repos: + norm = _normalize_path(root) + if not norm: + continue + rows.append((norm, (label or os.path.basename(norm) or norm), now)) + + with write_txn(conn): + if replace: + conn.execute("DELETE FROM discovered_repos") + if rows: + conn.executemany( + "INSERT INTO discovered_repos (root, label, last_seen) VALUES (?, ?, ?) " + "ON CONFLICT(root) DO UPDATE SET label = excluded.label, " + "last_seen = excluded.last_seen", + rows, + ) + return len(rows) + + +def list_discovered_repos(conn: sqlite3.Connection) -> List[dict]: + """All cached discovered repo roots, most-recently-seen first.""" + rows = conn.execute( + "SELECT root, label, last_seen FROM discovered_repos ORDER BY last_seen DESC" + ).fetchall() + return [ + {"root": r["root"], "label": r["label"], "last_seen": r["last_seen"]} + for r in rows + ] + + +# --------------------------------------------------------------------------- +# Resolution + naming +# --------------------------------------------------------------------------- + + +def project_for_path( + conn: sqlite3.Connection, path: str, *, include_archived: bool = False +) -> Optional[Project]: + """Return the project owning ``path`` (longest-prefix folder match). + + A folder owns ``path`` when ``path`` equals the folder or is nested under + it. The most specific (longest) folder wins, so nested projects resolve to + the innermost one. + """ + if not str(path or "").strip(): + return None + target = _normalize_path(path) + sql = ( + "SELECT pf.project_id AS pid, pf.path AS folder " + "FROM project_folders pf JOIN projects p ON p.id = pf.project_id" + ) + if not include_archived: + sql += " WHERE p.archived = 0" + best_pid: Optional[str] = None + best_len = -1 + for row in conn.execute(sql).fetchall(): + folder = row["folder"] + if target == folder or target.startswith(folder.rstrip("/\\") + os.sep) or \ + target.startswith(folder.rstrip("/\\") + "/"): + if len(folder) > best_len: + best_len = len(folder) + best_pid = row["pid"] + if best_pid is None: + return None + return get_project(conn, best_pid) + + +# Deterministic branch slug: lowercase, separators collapsed, capped. +_BRANCH_SAFE_RE = re.compile(r"[^a-z0-9._-]+") + + +def branch_name_for(project: Project, task_id: str, *, title: str = "") -> str: + """Deterministic branch name for a project-linked kanban task. + + Shape: ``/`` (optionally ``-``). Stable + and human-meaningful, replacing the random ``wt/`` fallback. + """ + slug = project.slug or _slugify(project.name) + base = f"{slug}/{task_id}" + if title: + tslug = _BRANCH_SAFE_RE.sub("-", str(title).strip().lower()).strip("-") + tslug = tslug[:40].strip("-") + if tslug: + base = f"{base}-{tslug}" + return base diff --git a/hermes_cli/sqlite_util.py b/hermes_cli/sqlite_util.py new file mode 100644 index 00000000000..e12a84b0303 --- /dev/null +++ b/hermes_cli/sqlite_util.py @@ -0,0 +1,49 @@ +"""Shared SQLite primitives for the small per-profile / board stores. + +The projects and kanban stores open WAL SQLite files with the same two +primitives — an idempotent column-add migration and an IMMEDIATE write +transaction. One definition here keeps the two stores from drifting. +""" + +from __future__ import annotations + +import contextlib +import sqlite3 + + +def add_column_if_missing(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> bool: + """``ALTER TABLE ADD COLUMN ``, idempotent across races. + + Returns ``True`` when this call added the column. Swallows the + ``duplicate column name`` error a concurrent migrator may have run first + (issue #21708). ``column`` is the human-readable name for the call site; + ``ddl`` carries the actual definition. + """ + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}") + return True + except sqlite3.OperationalError as exc: + if "duplicate column name" in str(exc).lower(): + return False + raise + + +@contextlib.contextmanager +def write_txn(conn: sqlite3.Connection): + """An IMMEDIATE write transaction: at most one concurrent writer wins. + + The explicit ROLLBACK is guarded so a SQLite auto-rollback (no active + transaction left under EIO / lock contention / corruption) cannot shadow + the original exception with a spurious rollback error. + """ + conn.execute("BEGIN IMMEDIATE") + try: + yield conn + except Exception: + try: + conn.execute("ROLLBACK") + except sqlite3.OperationalError: + pass + raise + else: + conn.execute("COMMIT") diff --git a/tests/hermes_cli/test_projects_cli.py b/tests/hermes_cli/test_projects_cli.py new file mode 100644 index 00000000000..66135265af5 --- /dev/null +++ b/tests/hermes_cli/test_projects_cli.py @@ -0,0 +1,84 @@ +"""Tests for the `hermes project` CLI dispatch (hermes_cli/projects_cmd).""" + +from __future__ import annotations + +import argparse + +import pytest + +from hermes_cli import projects_cmd +from hermes_cli import projects_db as pdb + + +def _run(argv): + """Build the project subparser, parse argv, and dispatch. Returns rc.""" + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + p = projects_cmd.build_parser(sub) + p.set_defaults(func=projects_cmd.projects_command) + args = parser.parse_args(["project", *argv]) + return projects_cmd.projects_command(args) + + +def test_create_list_show(capsys, tmp_path): + assert _run(["create", "My App", str(tmp_path), "--use"]) == 0 + out = capsys.readouterr().out + assert "Created project" in out + + with pdb.connect_closing() as conn: + projects = pdb.list_projects(conn) + assert len(projects) == 1 + assert projects[0].name == "My App" + # --use set it active. + assert pdb.get_active_id(conn) == projects[0].id + + assert _run(["list"]) == 0 + assert "my-app" in capsys.readouterr().out + + assert _run(["show", "my-app"]) == 0 + assert "My App" in capsys.readouterr().out + + +def test_add_remove_folder(tmp_path): + _run(["create", "P", str(tmp_path / "a")]) + assert _run(["add-folder", "p", str(tmp_path / "b")]) == 0 + + with pdb.connect_closing() as conn: + proj = pdb.get_project(conn, "p") + assert len(proj.folders) == 2 + + assert _run(["remove-folder", "p", str(tmp_path / "b")]) == 0 + with pdb.connect_closing() as conn: + assert len(pdb.get_project(conn, "p").folders) == 1 + + +def test_rename_and_archive(tmp_path): + _run(["create", "Old Name", str(tmp_path)]) + assert _run(["rename", "old-name", "New Name"]) == 0 + with pdb.connect_closing() as conn: + assert pdb.get_project(conn, "old-name").name == "New Name" + + assert _run(["archive", "old-name"]) == 0 + with pdb.connect_closing() as conn: + assert pdb.list_projects(conn) == [] + assert len(pdb.list_projects(conn, include_archived=True)) == 1 + + assert _run(["restore", "old-name"]) == 0 + with pdb.connect_closing() as conn: + assert len(pdb.list_projects(conn)) == 1 + + +def test_use_clear(tmp_path): + _run(["create", "P", str(tmp_path)]) + _run(["use", "p"]) + with pdb.connect_closing() as conn: + assert pdb.get_active_id(conn) is not None + + _run(["use"]) + with pdb.connect_closing() as conn: + assert pdb.get_active_id(conn) is None + + +def test_unknown_project_returns_error(capsys, tmp_path): + assert _run(["show", "nope"]) == 1 + assert "no such project" in capsys.readouterr().err diff --git a/tests/hermes_cli/test_projects_db.py b/tests/hermes_cli/test_projects_db.py new file mode 100644 index 00000000000..ddcf73111c7 --- /dev/null +++ b/tests/hermes_cli/test_projects_db.py @@ -0,0 +1,174 @@ +"""Tests for the per-profile Projects store (hermes_cli/projects_db).""" + +from __future__ import annotations + +import os + +import pytest + +from hermes_cli import projects_db as pdb + + +@pytest.fixture +def conn(tmp_path): + c = pdb.connect(db_path=tmp_path / "projects.db") + try: + yield c + finally: + c.close() + + +def test_record_and_list_discovered_repos(conn): + n = pdb.record_discovered_repos(conn, [("/www/alpha", "alpha"), ("/www/beta", None)]) + assert n == 2 + + rows = {r["root"]: r["label"] for r in pdb.list_discovered_repos(conn)} + assert rows["/www/alpha"] == "alpha" + # Label defaults to the basename when not given. + assert rows["/www/beta"] == "beta" + + +def test_record_discovered_repos_upserts(conn): + pdb.record_discovered_repos(conn, [("/www/alpha", "old")]) + pdb.record_discovered_repos(conn, [("/www/alpha", "new")]) + + rows = pdb.list_discovered_repos(conn) + assert len(rows) == 1 + assert rows[0]["label"] == "new" + + +def test_record_discovered_repos_replace_drops_stale_rows(conn): + pdb.record_discovered_repos(conn, [("/www/alpha", "alpha"), ("/www/beta", "beta")]) + pdb.record_discovered_repos(conn, [("/www/alpha", "fresh")], replace=True) + + rows = {r["root"]: r["label"] for r in pdb.list_discovered_repos(conn)} + assert rows == {"/www/alpha": "fresh"} + + +def test_create_get_list(conn): + pid = pdb.create_project(conn, name="Hermes Agent", folders=["/tmp/hermes"]) + proj = pdb.get_project(conn, pid) + + assert proj is not None + assert proj.slug == "hermes-agent" + assert proj.name == "Hermes Agent" + # First folder becomes primary. + assert proj.primary_path == "/tmp/hermes" + assert [f.path for f in proj.folders] == ["/tmp/hermes"] + assert proj.folders[0].is_primary is True + + # Lookup by slug too. + assert pdb.get_project(conn, "hermes-agent").id == pid + assert len(pdb.list_projects(conn)) == 1 + + +def test_slug_collision_disambiguates(conn): + pdb.create_project(conn, name="Hermes Agent") + pdb.create_project(conn, name="Hermes Agent") + slugs = sorted(p.slug for p in pdb.list_projects(conn)) + + assert slugs == ["hermes-agent", "hermes-agent-2"] + + +def test_empty_name_rejected(conn): + with pytest.raises(ValueError): + pdb.create_project(conn, name=" ") + + +def test_add_remove_folder_and_primary_repoint(conn): + pid = pdb.create_project(conn, name="P", folders=["/a"]) + pdb.add_folder(conn, pid, "/b") + pdb.add_folder(conn, pid, "/c", is_primary=True) + + proj = pdb.get_project(conn, pid) + assert proj.primary_path == "/c" + assert {f.path for f in proj.folders} == {"/a", "/b", "/c"} + + # Removing the primary repoints to the oldest remaining folder. + pdb.remove_folder(conn, pid, "/c") + proj = pdb.get_project(conn, pid) + assert proj.primary_path == "/a" + + # Removing the last folder clears the primary. + pdb.remove_folder(conn, pid, "/a") + pdb.remove_folder(conn, pid, "/b") + proj = pdb.get_project(conn, pid) + assert proj.primary_path is None + assert proj.folders == [] + + +def test_set_primary_requires_existing_folder(conn): + pid = pdb.create_project(conn, name="P", folders=["/a"]) + assert pdb.set_primary(conn, pid, "/nope") is False + assert pdb.set_primary(conn, pid, "/a") is True + + +def test_paths_normalized(conn): + pid = pdb.create_project(conn, name="P", folders=["/a/b/../c/"]) + proj = pdb.get_project(conn, pid) + # Trailing slash stripped, .. collapsed. + assert proj.primary_path == "/a/c" + + +def test_project_for_path_longest_prefix(conn): + outer = pdb.create_project(conn, name="Outer", folders=["/www"]) + inner = pdb.create_project(conn, name="Inner", folders=["/www/app"]) + + assert pdb.project_for_path(conn, "/www/app/src/x.py").id == inner + assert pdb.project_for_path(conn, "/www/other").id == outer + assert pdb.project_for_path(conn, "/elsewhere") is None + # Segment-wise prefix only: /www/app must not match /www/application. + assert pdb.project_for_path(conn, "/www/application").id == outer + + +def test_project_for_path_skips_archived(conn): + pid = pdb.create_project(conn, name="P", folders=["/www/app"]) + pdb.archive_project(conn, pid) + + assert pdb.project_for_path(conn, "/www/app/src") is None + # Archived hidden from the default list but visible with include_archived. + assert pdb.list_projects(conn) == [] + assert len(pdb.list_projects(conn, include_archived=True)) == 1 + + pdb.restore_project(conn, pid) + assert pdb.project_for_path(conn, "/www/app/src").id == pid + + +def test_active_pointer(conn): + pid = pdb.create_project(conn, name="P") + assert pdb.get_active_id(conn) is None + + pdb.set_active(conn, pid) + assert pdb.get_active_id(conn) == pid + + pdb.set_active(conn, None) + assert pdb.get_active_id(conn) is None + + +def test_branch_name_for_is_deterministic(): + proj = pdb.Project(id="p_1", slug="web-app", name="Web App", created_at=0) + + assert pdb.branch_name_for(proj, "t_abc") == "web-app/t_abc" + assert pdb.branch_name_for(proj, "t_abc", title="Add login!") == "web-app/t_abc-add-login" + # Stable across calls. + assert pdb.branch_name_for(proj, "t_abc") == pdb.branch_name_for(proj, "t_abc") + + +def test_per_profile_isolation(tmp_path): + # Two distinct DB paths stand in for two profiles' HERMES_HOME. + a = pdb.connect(db_path=tmp_path / "a" / "projects.db") + b = pdb.connect(db_path=tmp_path / "b" / "projects.db") + try: + pdb.create_project(a, name="Only In A", folders=["/a"]) + + assert [p.slug for p in pdb.list_projects(a)] == ["only-in-a"] + assert pdb.list_projects(b) == [] + finally: + a.close() + b.close() + + +def test_db_path_under_hermes_home(): + # Resolves under HERMES_HOME (set by the autouse isolation fixture). + assert pdb.projects_db_path().name == "projects.db" + assert os.path.basename(str(pdb.projects_db_path().parent)) # non-empty parent From e7811345c177de43743bdbab0ab39703d0fcc239 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:26 -0500 Subject: [PATCH 03/19] feat(kanban): link tasks to project worktrees --- hermes_cli/kanban.py | 6 ++ hermes_cli/kanban_db.py | 101 +++++++++++++++---- tests/hermes_cli/test_kanban_project_link.py | 73 ++++++++++++++ tools/kanban_tools.py | 16 +++ 4 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 tests/hermes_cli/test_kanban_project_link.py diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index db83b9f64f8..347165b6269 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -69,6 +69,7 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]: "workspace_kind": t.workspace_kind, "workspace_path": t.workspace_path, "branch_name": t.branch_name, + "project_id": t.project_id, "created_by": t.created_by, "created_at": t.created_at, "started_at": t.started_at, @@ -314,6 +315,10 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu "(default: scratch)") p_create.add_argument("--branch", default=None, help="Branch name for worktree tasks, e.g. wt/t6-wire") + p_create.add_argument("--project", default=None, + help="Link to a project (id or slug). Anchors the task's " + "worktree under the project's primary repo with a " + "deterministic branch. See `hermes project list`.") p_create.add_argument("--tenant", default=None, help="Tenant namespace") p_create.add_argument("--priority", type=int, default=0, help="Priority tiebreaker") p_create.add_argument("--triage", action="store_true", @@ -1320,6 +1325,7 @@ def _cmd_create(args: argparse.Namespace) -> int: workspace_kind=ws_kind, workspace_path=ws_path, branch_name=branch_name, + project_id=getattr(args, "project", None), tenant=args.tenant, priority=args.priority, parents=tuple(args.parent or ()), diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index c3107e37d75..5e014975589 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -88,6 +88,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Optional +from hermes_cli.sqlite_util import add_column_if_missing as _add_column_if_missing from toolsets import get_toolset_names _log = logging.getLogger(__name__) @@ -785,6 +786,7 @@ class Task: claim_expires: Optional[int] tenant: Optional[str] branch_name: Optional[str] = None + project_id: Optional[str] = None result: Optional[str] = None idempotency_key: Optional[str] = None # Unified non-success counter. Incremented on any of: @@ -863,6 +865,7 @@ class Task: workspace_kind=row["workspace_kind"], workspace_path=row["workspace_path"], branch_name=row["branch_name"] if "branch_name" in keys else None, + project_id=row["project_id"] if "project_id" in keys else None, claim_lock=row["claim_lock"], claim_expires=row["claim_expires"], tenant=row["tenant"] if "tenant" in keys else None, @@ -1020,6 +1023,10 @@ CREATE TABLE IF NOT EXISTS tasks ( workspace_kind TEXT NOT NULL DEFAULT 'scratch', workspace_path TEXT, branch_name TEXT, + -- Optional link to a first-class Project (hermes_cli/projects_db). When set, + -- the task's worktree is anchored under the project's primary repo with a + -- deterministic branch name instead of a random wt/ fallback. + project_id TEXT, claim_lock TEXT, claim_expires INTEGER, tenant TEXT, @@ -1745,25 +1752,6 @@ def init_db( return path -def _add_column_if_missing( - conn: sqlite3.Connection, table: str, column: str, ddl: str -) -> bool: - """Run ``ALTER TABLE
ADD COLUMN ``, idempotent across races. - - Returns ``True`` when the column was actually added by this call. - Swallows ``duplicate column name`` errors so a concurrent connection - that ran the same migration first does not crash the dispatcher tick - (issue #21708). - """ - try: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}") - return True - except sqlite3.OperationalError as exc: - if "duplicate column name" in str(exc).lower(): - return False - raise - - def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: """Add columns that were introduced after v1 release to legacy DBs. @@ -1776,6 +1764,8 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: _add_column_if_missing(conn, "tasks", "result", "result TEXT") if "branch_name" not in cols: _add_column_if_missing(conn, "tasks", "branch_name", "branch_name TEXT") + if "project_id" not in cols: + _add_column_if_missing(conn, "tasks", "project_id", "project_id TEXT") if "idempotency_key" not in cols: _add_column_if_missing( conn, "tasks", "idempotency_key", "idempotency_key TEXT" @@ -2262,6 +2252,7 @@ def create_task( initial_status: str = "running", session_id: Optional[str] = None, board: Optional[str] = None, + project_id: Optional[str] = None, ) -> str: """Create a new task and optionally link it under parent tasks. @@ -2302,6 +2293,48 @@ def create_task( branch_name = str(branch_name).strip() or None if branch_name and workspace_kind != "worktree": raise ValueError("branch_name is only valid for worktree workspaces") + + # Resolve an optional first-class Project link. A project-linked task is + # anchored to the project's primary repo as a git worktree, so its branch + # can be named deterministically (project slug + task id) instead of the + # random ``wt/`` fallback the worker skill applies when no branch + # is set. Projects live in the creator's per-profile projects.db; the repo + # path is absolute (profile-independent) and the branch name is pure, so the + # cross-profile dispatcher needs no projects.db access at dispatch time. + project_obj = None + # Primary repo of a project-linked worktree task whose path we still need to + # derive (a fresh worktree dir under the repo, computed once task_id exists). + project_repo: Optional[str] = None + if project_id is not None: + project_id = str(project_id).strip() or None + if project_id: + try: + from hermes_cli import projects_db as _pdb + + with _pdb.connect_closing() as _pconn: + project_obj = _pdb.get_project(_pconn, project_id) + except Exception: + project_obj = None + if project_obj is None: + # A project id/slug that doesn't resolve must not crash task + # creation or persist a dangling reference — drop the link and + # create the task as an ordinary (scratch) task. + project_id = None + else: + # Canonicalise (a slug may have been passed) and anchor the + # worktree under the project's primary repo. + project_id = project_obj.id + if workspace_kind == "scratch" and project_obj.primary_path: + workspace_kind = "worktree" + if ( + workspace_kind == "worktree" + and workspace_path is None + and project_obj.primary_path + ): + # Defer the concrete path to the insert loop: it's a fresh + # ``/.worktrees/`` dir keyed on the new task id. + project_repo = str(project_obj.primary_path) + parents = tuple(p for p in parents if p) # Normalise + validate skills: strip whitespace, drop empties, dedupe @@ -2375,7 +2408,11 @@ def create_task( # task would point cleanup at the user's source tree (#28818). The # containment guard in ``_cleanup_workspace`` is the safety rail, but # we also stop the bad state from being created in the first place. - if workspace_path is None and workspace_kind in {"dir", "worktree"}: + if ( + workspace_path is None + and project_repo is None + and workspace_kind in {"dir", "worktree"} + ): board_slug = board if board else get_current_board() board_meta = read_board_metadata(board_slug) board_default = board_meta.get("default_workdir") @@ -2419,14 +2456,33 @@ def create_task( if missing: raise ValueError(f"unknown parent task(s): {', '.join(missing)}") + # Project-linked worktree: a fresh worktree dir under the repo + # plus a deterministic branch (project slug + task id). Together + # these kill the random ``wt/`` worker fallback and the + # unanchored ``.worktrees/`` under the dispatcher's cwd. + if project_obj is not None and workspace_kind == "worktree": + if project_repo and not workspace_path: + workspace_path = os.path.join( + project_repo, ".worktrees", task_id + ) + if not branch_name: + # _pdb was imported above when project_obj was resolved. + try: + branch_name = _pdb.branch_name_for( + project_obj, task_id, title=title or "" + ) + except Exception: + branch_name = None + conn.execute( """ INSERT INTO tasks ( id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, - branch_name, tenant, idempotency_key, max_runtime_seconds, + branch_name, project_id, tenant, idempotency_key, + max_runtime_seconds, skills, max_retries, goal_mode, goal_max_turns, session_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, @@ -2440,6 +2496,7 @@ def create_task( workspace_kind, workspace_path, branch_name, + project_id, tenant, idempotency_key, int(max_runtime_seconds) if max_runtime_seconds is not None else None, diff --git a/tests/hermes_cli/test_kanban_project_link.py b/tests/hermes_cli/test_kanban_project_link.py new file mode 100644 index 00000000000..560d8974be8 --- /dev/null +++ b/tests/hermes_cli/test_kanban_project_link.py @@ -0,0 +1,73 @@ +"""Kanban <-> Projects integration: project-linked tasks get a deterministic +worktree path + branch instead of the random ``wt/`` fallback.""" + +from __future__ import annotations + +import os + +import pytest + +from hermes_cli import kanban_db as kb +from hermes_cli import projects_db as pdb + + +@pytest.fixture +def kanban_conn(tmp_path): + c = kb.connect(db_path=tmp_path / "kanban.db") + try: + yield c + finally: + c.close() + + +def _make_project(name="Web App", repo="/tmp/webapp"): + with pdb.connect_closing() as pc: + pid = pdb.create_project(pc, name=name, folders=[repo]) + return pdb.get_project(pc, pid) + + +def test_project_linked_task_gets_deterministic_worktree_and_branch(kanban_conn): + proj = _make_project() + tid = kb.create_task(kanban_conn, title="Add login", project_id=proj.slug) + task = kb.get_task(kanban_conn, tid) + + assert task.project_id == proj.id + assert task.workspace_kind == "worktree" + # Worktree dir anchored under the project's primary repo, keyed on task id. + assert task.workspace_path == os.path.join(proj.primary_path, ".worktrees", tid) + # Deterministic branch: /-. NOT a random wt/... + assert task.branch_name == f"{proj.slug}/{tid}-add-login" + assert not task.branch_name.startswith("wt/") + + +def test_explicit_branch_overrides_project_default(kanban_conn): + proj = _make_project() + tid = kb.create_task( + kanban_conn, + title="x", + project_id=proj.slug, + workspace_kind="worktree", + branch_name="feature/custom", + ) + task = kb.get_task(kanban_conn, tid) + assert task.branch_name == "feature/custom" + + +def test_unlinked_task_unchanged(kanban_conn): + tid = kb.create_task(kanban_conn, title="plain") + task = kb.get_task(kanban_conn, tid) + + assert task.project_id is None + assert task.workspace_kind == "scratch" + # No branch is persisted — the worker still owns the wt/ fallback for + # genuinely ad-hoc worktree tasks, but unlinked scratch tasks have none. + assert task.branch_name is None + + +def test_unknown_project_id_falls_back_gracefully(kanban_conn): + # A project id that doesn't resolve must not crash task creation; the task + # is created as-is (scratch) and project_id stays unset. + tid = kb.create_task(kanban_conn, title="x", project_id="does-not-exist") + task = kb.get_task(kanban_conn, tid) + assert task.workspace_kind == "scratch" + assert task.project_id is None diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index d997305b406..1e4e70f7a4f 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -321,6 +321,7 @@ def _task_summary_dict(kb, conn, task) -> dict[str, Any]: "tenant": task.tenant, "workspace_kind": task.workspace_kind, "workspace_path": task.workspace_path, + "project_id": task.project_id, "created_by": task.created_by, "created_at": task.created_at, "started_at": task.started_at, @@ -767,6 +768,7 @@ def _handle_create(args: dict, **kw) -> str: # fall back to scratch as before. Explicit None path stays None. workspace_kind = args.get("workspace_kind") workspace_path = args.get("workspace_path") + project_id = args.get("project") or args.get("project_id") _inherit_workspace = workspace_kind is None and workspace_path is None if workspace_kind is None: workspace_kind = "scratch" @@ -807,6 +809,10 @@ def _handle_create(args: dict, **kw) -> str: if _self_task is not None and _self_task.workspace_kind: workspace_kind = _self_task.workspace_kind workspace_path = _self_task.workspace_path + # Keep follow-up children inside the same project so the + # whole subtree shares one repo + branch convention. + if project_id is None and _self_task.project_id: + project_id = _self_task.project_id new_tid = kb.create_task( conn, title=str(title).strip(), @@ -817,6 +823,7 @@ def _handle_create(args: dict, **kw) -> str: priority=int(priority) if priority is not None else 0, workspace_kind=str(workspace_kind), workspace_path=workspace_path, + project_id=project_id, triage=triage, idempotency_key=idempotency_key, max_runtime_seconds=( @@ -1343,6 +1350,15 @@ KANBAN_CREATE_SCHEMA = { "Relative paths are rejected at dispatch." ), }, + "project": { + "type": "string", + "description": ( + "Optional project id or slug to link the task to. When " + "set, the task becomes a git worktree under the project's " + "primary repo with a deterministic branch (project slug + " + "task id), instead of a random branch." + ), + }, "triage": { "type": "boolean", "description": ( From 4e023f5bc990ac430d38a220c74162bbe92293f9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH 04/19] feat(gateway): build authoritative project tree --- hermes_cli/web_server.py | 3 + tests/test_tui_gateway_server.py | 35 +- tests/tui_gateway/test_project_tree.py | 352 ++++++++++++ tests/tui_gateway/test_projects_rpc.py | 237 ++++++++ tui_gateway/git_probe.py | 187 +++++++ tui_gateway/project_tree.py | 558 ++++++++++++++++++ tui_gateway/server.py | 747 +++++++++++++++++++++++-- 7 files changed, 2073 insertions(+), 46 deletions(-) create mode 100644 tests/tui_gateway/test_project_tree.py create mode 100644 tests/tui_gateway/test_projects_rpc.py create mode 100644 tui_gateway/git_probe.py create mode 100644 tui_gateway/project_tree.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b88c6a20475..75ae10bf50a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2977,6 +2977,7 @@ async def get_sessions( order: str = "created", source: str = None, exclude_sources: str = None, + cwd_prefix: str = None, profile: Optional[str] = None, ): """List sessions. @@ -3018,6 +3019,7 @@ async def get_sessions( sessions = db.list_sessions_rich( source=source or None, exclude_sources=exclude_list or None, + cwd_prefix=(cwd_prefix or None), limit=limit, offset=offset, min_message_count=min_message_count, @@ -3027,6 +3029,7 @@ async def get_sessions( ) total = db.session_count( source=source or None, + cwd_prefix=(cwd_prefix or None), exclude_sources=exclude_list or None, min_message_count=min_message_count, include_archived=include_archived, diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index e761e46158c..fe42ebcc232 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -703,6 +703,19 @@ def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch): assert server._load_enabled_toolsets() == ["plugin_demo"] +def test_load_enabled_toolsets_folds_project_into_focus_posture(monkeypatch): + # Focus-mode coding posture returns before the config fallback, but it's + # still a GUI-only resolver — `project` must come along so the desktop keeps + # the project tools while sitting in a repo. + monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False) + + import agent.coding_context as cc + + monkeypatch.setattr(cc, "coding_selection", lambda **_: ["coding", "figma"]) + + assert server._load_enabled_toolsets() == ["coding", "figma", "project"] + + def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys): monkeypatch.setenv("HERMES_TUI_TOOLSETS", "mcp-off") monkeypatch.setitem( @@ -722,10 +735,10 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys): config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}} ) - # Sorted: ["kanban", "memory"]. `kanban` is auto-recovered by - # _get_platform_tools because it's a non-configurable platform toolset - # whose tools live in hermes-cli's universe (see toolsets.py). - assert server._load_enabled_toolsets() == ["kanban", "memory"] + # Sorted: ["kanban", "memory", "project"]. `kanban` is auto-recovered by + # _get_platform_tools (a non-configurable platform toolset in hermes-cli's + # universe); `project` is GUI-only, folded in by _load_enabled_toolsets. + assert server._load_enabled_toolsets() == ["kanban", "memory", "project"] err = capsys.readouterr().err assert "ignoring disabled MCP servers" in err assert "mcp-off" in err @@ -746,7 +759,7 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}} ) - assert server._load_enabled_toolsets() == ["kanban", "memory"] + assert server._load_enabled_toolsets() == ["kanban", "memory", "project"] assert "using configured CLI toolsets" in capsys.readouterr().err @@ -1172,7 +1185,7 @@ def test_session_cwd_set_profile_session_updates_profile_db(monkeypatch, tmp_pat captured = {} class ProfileDB: - def update_session_cwd(self, session_id, cwd): + def update_session_cwd(self, session_id, cwd, git_branch=None, git_repo_root=None): captured["profile_update"] = (session_id, cwd) def close(self): @@ -2009,7 +2022,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -2028,7 +2041,7 @@ def test_ensure_session_db_row_persists_session_source(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -2049,7 +2062,7 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -2076,7 +2089,7 @@ def test_ensure_session_db_row_persists_session_model_override(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append( {"key": key, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -2108,7 +2121,7 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append({"model": model, "model_config": model_config}) monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py new file mode 100644 index 00000000000..0958a769688 --- /dev/null +++ b/tests/tui_gateway/test_project_tree.py @@ -0,0 +1,352 @@ +"""Invariants for the authoritative project-tree builder (tui_gateway.project_tree). + +These assert structural contracts (worktree folding, kanban collapse, lane id +scheme, membership union) rather than snapshots, so routine data changes don't +break them. +""" + +from __future__ import annotations + +from tui_gateway import project_tree as pt + +_SID = 0 + + +def _session(cwd, *, branch="", repo_root="", **over): + global _SID + _SID += 1 + row = { + "id": f"s{_SID}", + "cwd": cwd, + "git_branch": branch, + "git_repo_root": repo_root, + "started_at": 1000, + "last_active": 1000, + "title": None, + "preview": None, + "source": "cli", + } + row.update(over) + return row + + +def _project(pid, name, folders, **over): + row = { + "id": pid, + "name": name, + "primary_path": folders[0] if folders else None, + "archived": False, + "folders": [{"path": p, "is_primary": i == 0} for i, p in enumerate(folders)], + } + row.update(over) + return row + + +def _resolver(mapping): + """Build a resolve() from {cwd: (repo_root, worktree_root)}.""" + + def resolve(cwd): + hit = mapping.get(cwd) + if not hit: + return None + return {"repo_root": hit[0], "worktree_root": hit[1]} + + return resolve + + +def _lane_ids(project): + return [g["id"] for repo in project["repos"] for g in repo["groups"]] + + +# --------------------------------------------------------------------------- + + +def test_main_checkout_groups_by_recorded_branch_with_stable_lane_ids(): + resolve = _resolver({"/repo": ("/repo", "/repo")}) + sessions = [ + _session("/repo", branch="main"), + _session("/repo", branch="feature"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = next(p for p in tree["projects"] if p["id"] == "/repo") + + assert project["isAuto"] is True + assert _lane_ids(project) == ["/repo::branch::main", "/repo::branch::feature"] + # Trunk sorts ahead of the feature branch; both live in the main checkout. + assert [g["label"] for repo in project["repos"] for g in repo["groups"]] == ["main", "feature"] + assert all(g["isMain"] for repo in project["repos"] for g in repo["groups"]) + + +def test_linked_worktrees_fold_under_their_common_repo_root(): + # The linked worktree's own toplevel is /elsewhere/wt, but its COMMON root is + # /repo, so it must group under /repo (not as a separate project). + resolve = _resolver( + { + "/repo": ("/repo", "/repo"), + "/elsewhere/wt": ("/repo", "/elsewhere/wt"), + } + ) + sessions = [ + _session("/repo", branch="main"), + _session("/elsewhere/wt", branch="feature"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["/repo"] + project = tree["projects"][0] + assert project["repos"][0]["id"] == "/repo" + lane_ids = _lane_ids(project) + assert "/repo::branch::main" in lane_ids + # Linked worktree lane is keyed by the worktree path and is not main. + linked = next(g for repo in project["repos"] for g in repo["groups"] if not g["isMain"]) + assert linked["id"] == "/elsewhere/wt" + assert linked["path"] == "/elsewhere/wt" + + +def test_kanban_task_worktrees_collapse_into_one_bucket(): + resolve = _resolver( + { + "/repo": ("/repo", "/repo"), + "/repo/.worktrees/t_aaaaaaaa": ("/repo", "/repo/.worktrees/t_aaaaaaaa"), + "/repo/.worktrees/t_bbbbbbbb": ("/repo", "/repo/.worktrees/t_bbbbbbbb"), + } + ) + sessions = [ + _session("/repo", branch="main"), + _session("/repo/.worktrees/t_aaaaaaaa"), + _session("/repo/.worktrees/t_bbbbbbbb"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = tree["projects"][0] + kanban = [g for repo in project["repos"] for g in repo["groups"] if g.get("isKanban")] + + assert len(kanban) == 1 + assert kanban[0]["id"] == "/repo::kanban" + assert kanban[0]["path"] == "/repo/.worktrees" + assert len(kanban[0]["sessions"]) == 2 + # The bucket sorts below the real main branch. + assert _lane_ids(project)[-1] == "/repo::kanban" + + +def test_user_worktree_under_dotworktrees_is_its_own_lane_not_kanban(): + # A user "New worktree" lives at /.worktrees/ (no t_ id), so it + # must NOT collapse into the kanban bucket — it gets its own linked lane. + resolve = _resolver( + { + "/repo": ("/repo", "/repo"), + "/repo/.worktrees/test-gui-stuff": ("/repo", "/repo/.worktrees/test-gui-stuff"), + } + ) + sessions = [ + _session("/repo", branch="main"), + _session("/repo/.worktrees/test-gui-stuff", branch="hermes/test-gui-stuff"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = tree["projects"][0] + lanes = {g["id"]: g for repo in project["repos"] for g in repo["groups"]} + + assert "/repo/.worktrees/test-gui-stuff" in lanes + assert not lanes["/repo/.worktrees/test-gui-stuff"].get("isKanban") + assert "/repo::kanban" not in lanes + + +def test_unrecorded_and_recorded_main_share_one_lane(): + # Empty git_branch (historical sessions) folds into the same trunk lane as + # sessions that recorded branch "main" — no duplicate "main". + resolve = _resolver({"/repo": ("/repo", "/repo")}) + sessions = [_session("/repo", branch=""), _session("/repo", branch="main")] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = tree["projects"][0] + main_lanes = [g for repo in project["repos"] for g in repo["groups"] if g["label"] == "main"] + + assert len(main_lanes) == 1 + assert main_lanes[0]["id"] == "/repo::branch::main" + assert len(main_lanes[0]["sessions"]) == 2 + + +def test_persisted_repo_root_used_when_no_live_probe(): + # No resolver (remote backend): fall back to the persisted git_repo_root and + # split the main checkout by the session's recorded branch. + sessions = [_session("/repo/src", branch="main", repo_root="/repo")] + + tree = pt.build_tree([], sessions, [], resolve=None, hydrate=True) + project = next(p for p in tree["projects"] if p["id"] == "/repo") + + assert _lane_ids(project) == ["/repo::branch::main"] + + +def test_explicit_project_claims_sessions_and_beats_auto(): + project = _project("p_app", "App", ["/www/app"]) + resolve = _resolver( + { + "/www/app": ("/www/app", "/www/app"), + "/www/other": ("/www/other", "/www/other"), + } + ) + sessions = [ + _session("/www/app", branch="main"), + _session("/www/other", branch="main"), + ] + + tree = pt.build_tree([project], sessions, [], resolve, hydrate=True) + + explicit = next(p for p in tree["projects"] if p["id"] == "p_app") + assert explicit["isAuto"] is False + assert explicit["sessionCount"] == 1 + # The unowned /www/other session becomes its own auto project. + assert any(p["id"] == "/www/other" and p["isAuto"] for p in tree["projects"]) + + +def test_scoped_session_ids_is_union_of_placed_sessions(): + project = _project("p_app", "App", ["/www/app"]) + resolve = _resolver( + { + "/www/app": ("/www/app", "/www/app"), + "/www/repo": ("/www/repo", "/www/repo"), + } + ) + owned = _session("/www/app", branch="main") + auto = _session("/www/repo", branch="main") + homeless = _session(None) # no cwd -> belongs to no project + + tree = pt.build_tree([project], [owned, auto, homeless], [], resolve, hydrate=True) + + assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"]} + assert homeless["id"] not in tree["scoped_session_ids"] + + +def test_overview_drops_session_rows_but_keeps_counts_and_previews(): + resolve = _resolver({"/repo": ("/repo", "/repo")}) + sessions = [_session("/repo", branch="main") for _ in range(4)] + + tree = pt.build_tree([], sessions, [], resolve, preview_limit=3, hydrate=False) + project = tree["projects"][0] + + assert project["sessionCount"] == 4 + assert len(project["previewSessions"]) == 3 + # Lanes carry structure + counts but no rows in overview mode. + assert all(g["sessions"] == [] for repo in project["repos"] for g in repo["groups"]) + assert project["repos"][0]["sessionCount"] == 4 + + +def test_discovered_repo_with_no_sessions_becomes_zero_session_project(): + discovered = [{"root": "/www/fresh", "label": "fresh", "sessions": 0, "last_active": 5}] + + tree = pt.build_tree([], [], discovered, resolve=None, hydrate=False) + + fresh = next(p for p in tree["projects"] if p["id"] == "/www/fresh") + assert fresh["isAuto"] is True + assert fresh["sessionCount"] == 0 + assert fresh["repos"][0]["groups"] == [] + + +def test_explicit_project_with_no_sessions_seeds_its_folders_as_repos(): + # A brand-new (or unloaded) project must still expose its declared folders as + # repos so the entered view renders and the desktop's optimistic overlay has a + # lane to place a freshly-created session into (otherwise it only shows after a + # full tree refresh). + project = _project("p_new", "New", ["/work/blank"]) + + tree = pt.build_tree([project], [], [], resolve=None, hydrate=True) + + node = next(p for p in tree["projects"] if p["id"] == "p_new") + assert node["sessionCount"] == 0 + assert [r["path"] for r in node["repos"]] == ["/work/blank"] + assert node["repos"][0]["groups"] == [] + + +def test_seeded_folder_repo_does_not_duplicate_a_session_derived_repo(): + # When a folder already has sessions (same git root), seeding must not add a + # second repo for the same path. + project = _project("p_app", "App", ["/www/app"]) + resolve = _resolver({"/www/app": ("/www/app", "/www/app")}) + sessions = [_session("/www/app", branch="main")] + + tree = pt.build_tree([project], sessions, [], resolve, hydrate=True) + + node = next(p for p in tree["projects"] if p["id"] == "p_app") + assert [r["path"] for r in node["repos"]] == ["/www/app"] + + +def test_discovered_repo_owned_by_explicit_project_is_not_duplicated(): + project = _project("p_app", "App", ["/www/app"]) + discovered = [{"root": "/www/app", "label": "app", "sessions": 2, "last_active": 1}] + + tree = pt.build_tree([project], [], discovered, resolve=None, hydrate=False) + + assert [p["id"] for p in tree["projects"] if p["path"] == "/www/app"] == ["p_app"] + + +def test_nested_project_folders_pick_the_deepest_match(): + # The folder index must resolve a session to its most-specific (deepest) + # project folder, not just any ancestor. + outer = _project("p_outer", "Outer", ["/work"]) + inner = _project("p_inner", "Inner", ["/work/app"]) + resolve = _resolver( + { + "/work/app": ("/work/app", "/work/app"), + "/work/other": ("/work/other", "/work/other"), + } + ) + + tree = pt.build_tree( + [outer, inner], + [_session("/work/app", branch="main"), _session("/work/other", branch="main")], + [], + resolve, + hydrate=True, + ) + by_id = {p["id"]: p for p in tree["projects"]} + + assert by_id["p_inner"]["sessionCount"] == 1 # /work/app → deepest folder wins + assert by_id["p_outer"]["sessionCount"] == 1 # /work/other → only the outer project + + +def test_junk_root_never_becomes_an_auto_project(): + # A session whose git root is HERMES_HOME (config/state) must not spawn a + # phantom project; it falls through to flat Recents (unscoped). A real repo + # alongside it still groups normally. + resolve = _resolver( + { + "/home/me/.hermes": ("/home/me/.hermes", "/home/me/.hermes"), + "/www/app": ("/www/app", "/www/app"), + } + ) + junk = _session("/home/me/.hermes", branch="main") + real = _session("/www/app", branch="main") + is_junk = lambda root: root == "/home/me/.hermes" + + tree = pt.build_tree([], [junk, real], [], resolve, hydrate=True, is_junk_root=is_junk) + + ids = {p["id"] for p in tree["projects"]} + assert ids == {"/www/app"} + assert junk["id"] not in tree["scoped_session_ids"] + assert real["id"] in tree["scoped_session_ids"] + + +def test_junk_root_is_dropped_from_the_discovered_tier(): + discovered = [{"root": "/home/me/.hermes", "label": ".hermes", "sessions": 0, "last_active": 9}] + + tree = pt.build_tree([], [], discovered, resolve=None, is_junk_root=lambda r: r == "/home/me/.hermes") + + assert tree["projects"] == [] + + +def test_colliding_repo_basenames_disambiguate_labels(): + resolve = _resolver( + { + "/x/proj": ("/x/proj", "/x/proj"), + "/y/proj": ("/y/proj", "/y/proj"), + } + ) + sessions = [_session("/x/proj", branch="main"), _session("/y/proj", branch="main")] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + labels = sorted(p["label"] for p in tree["projects"]) + + assert labels == ["x/proj", "y/proj"] diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py new file mode 100644 index 00000000000..ca65803d5ae --- /dev/null +++ b/tests/tui_gateway/test_projects_rpc.py @@ -0,0 +1,237 @@ +"""Tests for the projects.* JSON-RPC methods on the tui_gateway server.""" + +from __future__ import annotations + +import os +import subprocess + +import pytest + +import tui_gateway.server as server + + +def _call(method, params=None): + handler = server._methods[method] + resp = handler(1, params or {}) + assert "error" not in resp, resp.get("error") + return resp["result"] + + +def test_methods_registered(): + for m in ( + "projects.list", + "projects.create", + "projects.get", + "projects.update", + "projects.add_folder", + "projects.remove_folder", + "projects.set_primary", + "projects.archive", + "projects.set_active", + "projects.for_cwd", + ): + assert m in server._methods + + +def test_for_cwd_is_a_long_handler(): + # git-probe handler must run off the dispatch thread. + assert "projects.for_cwd" in server._LONG_HANDLERS + + +def test_repo_root_cache_does_not_freeze_a_not_yet_repo(monkeypatch): + # We `git init` a new project's folder on first worktree; the cache must not + # have frozen the pre-init "" result, or the main lane mislabels by basename. + # Negative results are TTL-cached; TTL=0 here makes them expire immediately so + # this verifies the "never permanently frozen" contract directly. + from tui_gateway import git_probe + + monkeypatch.setattr(git_probe, "_NEG_TTL", 0) + cwd = "/tmp/baby pics" + git_probe.invalidate() + state = {"root": ""} # flips once the folder becomes a repo + monkeypatch.setattr(git_probe, "run_git", lambda c, *a: state["root"] if c == cwd else "") + + assert git_probe.repo_root(cwd) == "" # pre-init: not a repo (expires at once) + + state["root"] = cwd # `git init` happened + assert git_probe.repo_root(cwd) == cwd # re-probed, not frozen + assert git_probe.repo_root(cwd) == cwd # now cached + + +def test_negative_results_are_ttl_cached_then_re_probed(monkeypatch): + # A non-repo cwd is re-derived on every session in a project-tree build, so a + # "not a repo" answer must be cached briefly to avoid re-spawning git dozens + # of times — but only until the TTL elapses, so a folder that later becomes a + # repo is still picked up. + from tui_gateway import git_probe + + git_probe.invalidate() + calls = {"n": 0} + + def probe(_cwd, *_a): + calls["n"] += 1 + return "" # never a repo + + monkeypatch.setattr(git_probe, "run_git", probe) + monkeypatch.setattr(git_probe, "_NEG_TTL", 1000) # effectively no expiry here + + cwd = "/not/a/repo" + assert git_probe.repo_root(cwd) == "" + for _ in range(10): + assert git_probe.repo_root(cwd) == "" + assert calls["n"] == 1 # cached: probed once, not 11 times + + # Once the TTL lapses, the next lookup re-probes (a `git init` may have run). + monkeypatch.setattr(git_probe, "_NEG_TTL", 0) + git_probe._cache._neg[cwd] = 0.0 # force-expire the cached negative + assert git_probe.repo_root(cwd) == "" + assert calls["n"] == 2 + + +def test_repo_root_cache_is_single_flight(monkeypatch): + # Concurrent identical probes share one git invocation (gateway long handlers + # run on worker threads). + import threading + + from tui_gateway import git_probe + + git_probe.invalidate() + calls = {"n": 0} + started = threading.Event() + + def slow(_cwd, *_a): + calls["n"] += 1 + started.set() + time = __import__("time") + time.sleep(0.05) + return "/repo" + + monkeypatch.setattr(git_probe, "run_git", slow) + out: list[str] = [] + threads = [threading.Thread(target=lambda: out.append(git_probe.repo_root("/repo/x"))) for _ in range(6)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert out == ["/repo"] * 6 + assert calls["n"] == 1 + + +def test_warm_roots_probes_in_parallel_and_fills_the_cache(monkeypatch): + # Cold first paint must not serialize one git subprocess per cwd. + import threading + import time + + from tui_gateway import git_probe + + git_probe.invalidate() + lock = threading.Lock() + live = {"now": 0, "peak": 0, "calls": 0} + + def slow(cwd, *_a): + with lock: + live["now"] += 1 + live["calls"] += 1 + live["peak"] = max(live["peak"], live["now"]) + time.sleep(0.02) + with lock: + live["now"] -= 1 + return cwd # show-toplevel → cwd is its own root + + monkeypatch.setattr(git_probe, "run_git", slow) + cwds = [f"/repo{i}" for i in range(8)] + git_probe.warm_roots(cwds, max_workers=8) + + assert live["peak"] > 1 # ran concurrently, not serialized + # Cache is warm: resolving again triggers no further probes. + before = live["calls"] + assert git_probe.repo_root("/repo0") == "/repo0" + assert live["calls"] == before + + +def test_create_list_roundtrip(tmp_path): + created = _call("projects.create", {"name": "Demo", "folders": [str(tmp_path)], "use": True}) + assert created["project"]["slug"] == "demo" + + listing = _call("projects.list") + assert [p["slug"] for p in listing["projects"]] == ["demo"] + assert listing["active_id"] == created["project"]["id"] + + +def test_add_folder_and_for_cwd(tmp_path): + folder = tmp_path / "repo" + folder.mkdir() + pid = _call("projects.create", {"name": "Repo", "folders": [str(folder)]})["project"]["id"] + + nested = folder / "src" + nested.mkdir() + resolved = _call("projects.for_cwd", {"cwd": str(nested)}) + assert resolved["project"]["id"] == pid + # branch key is present (empty string when not a git repo). + assert "branch" in resolved + + +def test_update_and_archive(tmp_path): + pid = _call("projects.create", {"name": "Orig", "folders": [str(tmp_path)]})["project"]["id"] + + updated = _call("projects.update", {"id": pid, "name": "Renamed"}) + assert updated["project"]["name"] == "Renamed" + + payload = _call("projects.archive", {"id": pid}) + assert all(p["id"] != pid or p["archived"] for p in payload["projects"]) + + +def test_get_unknown_returns_error(): + resp = server._methods["projects.get"](1, {"id": "nope"}) + assert "error" in resp + + +def test_delete_removes_project(tmp_path): + pid = _call("projects.create", {"name": "Doomed", "folders": [str(tmp_path)]})["project"]["id"] + payload = _call("projects.delete", {"id": pid}) + + assert all(p["id"] != pid for p in payload["projects"]) + assert "projects.delete" in server._methods + + +def test_discover_repos_is_registered_long_handler(): + assert "projects.discover_repos" in server._methods + assert "projects.discover_repos" in server._LONG_HANDLERS + assert "projects.record_repos" in server._methods + assert "projects.record_repos" in server._LONG_HANDLERS + + +def test_record_repos_persists_and_shows_zero_session_repo(tmp_path): + repo = tmp_path / "fresh-repo" + repo.mkdir() + + # Repo-first: a scanned repo with no hermes sessions still surfaces. + _call("projects.record_repos", {"repos": [{"root": str(repo), "label": "fresh-repo"}]}) + + by_label = {r["label"]: r for r in _call("projects.discover_repos")["repos"]} + assert "fresh-repo" in by_label + assert by_label["fresh-repo"]["sessions"] == 0 + + +def test_discover_repos_from_full_history(tmp_path): + repo = tmp_path / "myrepo" + (repo / "src").mkdir(parents=True) + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + plain = tmp_path / "plain" + plain.mkdir() + + db = server._get_db() + db.create_session("s1", "cli", cwd=str(repo)) + db.create_session("s2", "cli", cwd=str(repo / "src")) + db.create_session("s3", "cli", cwd=str(plain)) # not a git repo → excluded + + repos = _call("projects.discover_repos")["repos"] + by_label = {r["label"]: r for r in repos} + + assert "myrepo" in by_label + assert by_label["myrepo"]["sessions"] == 2 # both repo cwds aggregate + assert "plain" not in by_label # non-git dir never promoted + + # The probe is persisted back onto the session rows (membership at the source). + assert os.path.realpath(db.get_session("s1")["git_repo_root"]) == os.path.realpath(str(repo)) diff --git a/tui_gateway/git_probe.py b/tui_gateway/git_probe.py new file mode 100644 index 00000000000..01b7998ad14 --- /dev/null +++ b/tui_gateway/git_probe.py @@ -0,0 +1,187 @@ +"""Git working-tree probing for the gateway: run git, resolve repo roots, fold +linked worktrees under their common root. + +Probing runs where the gateway runs, so it resolves repos for both local and +remote backends (unlike the desktop's electron probe, which only sees the local +fs). Resolved roots are cached with a thread-safe, single-flight cache: the +gateway's long handlers run on worker threads, so concurrent identical probes +(e.g. two overlapping project-tree builds) share one `git` invocation instead of +racing an unguarded dict. + +Positive results are cached for the process lifetime; negative results (a cwd +that isn't a git repo, or a deleted/nonexistent dir) are cached only for a short +TTL (`_NEG_TTL`). Caching negatives matters a lot for the desktop Projects tree: +``project_tree.build_tree`` resolves a cwd once *per session* (not per distinct +cwd), so a power user with hundreds of sessions in non-git/deleted dirs would +otherwise re-spawn ``git`` hundreds of times on *every* sidebar open — the cause +of the multi-second "Projects" load. The TTL keeps a not-yet-repo cwd +re-probable (we `git init` a new project's folder on its first worktree, and a +frozen "" would mislabel its main lane by the dir basename) — it just stops the +same "not a repo" answer from being re-derived dozens of times within one build +and across rapid re-opens. `invalidate()` drops everything after a known +mutation. +""" + +from __future__ import annotations + +import os +import subprocess +import threading +import time +from collections.abc import Iterable +from concurrent.futures import ThreadPoolExecutor + +_GIT_TIMEOUT = 1.5 +_WARM_WORKERS = 8 + +# How long a "not a git repo" answer stays cached before it's re-probed. Short +# enough that a freshly `git init`-ed / newly-created folder shows correctly +# within a few seconds; long enough to collapse the hundreds of redundant probes +# a single project-tree build (and rapid re-opens) would otherwise fire. +_NEG_TTL = 30.0 + + +def run_git(cwd: str, *args: str) -> str: + """``git -C `` → stripped stdout, or ``""`` on any failure.""" + if not cwd: + return "" + try: + result = subprocess.run( + ["git", "-C", cwd, *args], + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + check=False, + stdin=subprocess.DEVNULL, + ) + return result.stdout.strip() if result.returncode == 0 else "" + except Exception: + return "" + + +def branch(cwd: str) -> str: + return run_git(cwd, "branch", "--show-current") or run_git(cwd, "rev-parse", "--short", "HEAD") + + +class _RootCache: + """Thread-safe, single-flight cache of git-root probes. Positive results are + cached for the process lifetime; negative ("not a repo") results are cached + only for ``_NEG_TTL`` seconds so a not-yet-repo cwd stays re-probable. + Followers wait on the leader's probe instead of duplicating it.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._roots: dict[str, str] = {} + self._neg: dict[str, float] = {} # key -> monotonic expiry + self._inflight: dict[str, threading.Event] = {} + + def invalidate(self) -> None: + with self._lock: + self._roots.clear() + self._neg.clear() + self._inflight.clear() + + def resolve(self, key: str, probe) -> str: + while True: + with self._lock: + hit = self._roots.get(key) + if hit: + return hit + expiry = self._neg.get(key) + if expiry is not None: + if expiry > time.monotonic(): + # Recently probed as "not a repo" — trust it briefly + # instead of re-spawning git for the same dead/non-repo + # cwd on every session in the tree build. + return "" + # TTL elapsed: drop it and re-probe (it may be a repo now). + del self._neg[key] + gate = self._inflight.get(key) + if gate is None: + gate = threading.Event() + self._inflight[key] = gate + leader = True + else: + leader = False + + if not leader: + # Another thread is probing this key — wait, then re-read. + gate.wait(timeout=_GIT_TIMEOUT + 0.5) + continue + + value = "" + try: + value = probe() + finally: + with self._lock: + if value: + self._roots[key] = value + else: + self._neg[key] = time.monotonic() + _NEG_TTL + self._inflight.pop(key, None) + gate.set() + return value + + +_cache = _RootCache() + + +def invalidate() -> None: + """Drop cached roots after a known mutation (e.g. a worktree was added).""" + _cache.invalidate() + + +def repo_root(cwd: str) -> str: + """Top-level git repo root for ``cwd`` (``""`` when not a repo).""" + if not cwd: + return "" + return _cache.resolve(cwd, lambda: run_git(cwd, "rev-parse", "--show-toplevel")) + + +def common_repo_root(cwd: str) -> str: + """The MAIN (common) repo root for ``cwd``, folding linked worktrees. + + ``--show-toplevel`` returns a linked worktree's OWN root, so grouping by it + splits every worktree into a separate "repo". The common ``.git`` dir + (``--git-common-dir``) is shared by a repo and all its worktrees, so its + parent is the one true repo root; fall back to the toplevel root otherwise. + """ + if not cwd: + return "" + + def _probe() -> str: + gitdir = run_git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir") + if gitdir: + gitdir = os.path.realpath(gitdir) + if os.path.basename(gitdir) == ".git": + return os.path.dirname(gitdir) + return repo_root(cwd) + + return _cache.resolve(f"common:{cwd}", _probe) + + +def resolve(cwd: str) -> dict | None: + """Inject-able resolver for ``project_tree.build_tree``. + + Returns ``{"repo_root": , "worktree_root": }`` + or ``None`` when ``cwd`` is not in a git repo. ``build_tree`` treats + ``worktree_root == repo_root`` as the main checkout. + """ + worktree_root = repo_root(cwd) + if not worktree_root: + return None + return {"repo_root": common_repo_root(cwd) or worktree_root, "worktree_root": worktree_root} + + +def warm_roots(cwds: Iterable[str], max_workers: int = _WARM_WORKERS) -> None: + """Pre-resolve many cwds' roots in parallel (bounded) so a cold first paint + doesn't serialize one git subprocess per session cwd. Single-flight dedupes + overlap; results land in the shared cache for the sequential consumers.""" + pending = sorted({(cwd or "").strip() for cwd in cwds} - {""}) + if not pending: + return + if len(pending) == 1: + resolve(pending[0]) + return + with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as pool: + list(pool.map(resolve, pending)) diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py new file mode 100644 index 00000000000..ded460f2811 --- /dev/null +++ b/tui_gateway/project_tree.py @@ -0,0 +1,558 @@ +"""Authoritative project -> repo -> lane -> session tree builder. + +This is the single source of truth for how the desktop sidebar groups sessions +into projects, repos, and lanes. It is pure (all git resolution is injected via +``resolve``) so it can be unit-tested with fixtures and reused by the gateway's +``projects.tree`` / ``projects.project_sessions`` RPCs. + +It deliberately mirrors the desktop's former client-side grouping (the old +``workspace-groups.ts``) so the emitted ids and lane keys stay byte-compatible +with the renderer's persisted state (pins, manual ordering, dismissal), which +all key off these exact strings: + + - explicit project id .......... ``p_`` (from projects.db) + - auto/discovered project id ... the repo root path + - repo node id ................. the repo root path + - main branch lane id .......... ``::branch::`` (or ``::branch::``) + - kanban bucket lane id ........ ``::kanban`` + - linked worktree lane id ...... the worktree path + +The one correctness upgrade over the client version: linked worktrees are folded +under their MAIN repo via a git common-dir probe (injected as ``resolve``), +instead of being treated as separate repos (``git rev-parse --show-toplevel`` +returns the worktree's own root, which is why the client double-counted them). +""" + +from __future__ import annotations + +import re +from typing import Any, Callable, Optional + +# A cwd -> git identity resolver. Returns ``{"repo_root", "worktree_root"}`` where +# ``repo_root`` is the COMMON (main) repo root shared across worktrees and +# ``worktree_root`` is this cwd's own checkout root. Returns ``None`` when the +# cwd is not in a git repo (or cannot be probed, e.g. a remote backend). +Resolve = Callable[[str], Optional[dict]] + +# Only KANBAN-TASK worktrees (`/.worktrees/t_`, the `t_…` id kanban_db +# mints) collapse into one lane; user-named "New worktree" dirs under +# `.worktrees/` stay as their own lanes. +_KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$") +_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"} +DEFAULT_BRANCH_LABEL = "main" + + +def _branch_lane_id(repo_root: str, branch: str = "") -> str: + """The one definition of a main-checkout lane id (must match the desktop).""" + return f"{repo_root}::branch::{(branch or '').strip()}" + + +def _kanban_lane_id(repo_root: str) -> str: + return f"{repo_root}::kanban" + + +# --------------------------------------------------------------------------- +# Path helpers (match the TS segment logic so labels/ids line up) +# --------------------------------------------------------------------------- + + +def _segments(path: str) -> list[str]: + return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s] + + +def base_name(path: str) -> str: + segs = _segments(path) + return segs[-1] if segs else "" + + +def kanban_worktree_dir(path: str) -> Optional[str]: + """The ``/.worktrees`` dir for a ``.../.worktrees/`` path, else None.""" + m = _KANBAN_DIR_RE.match(path or "") + return m.group(1) if m else None + + +def _is_path_under(folder: str, target: str) -> bool: + """True when ``target`` equals ``folder`` or is nested under it (segment-wise).""" + f = _segments(folder) + t = _segments(target) + if not f or len(f) > len(t): + return False + return all(f[i] == t[i] for i in range(len(f))) + + +def _with_base_name(path: str, name: str) -> str: + stripped = re.sub(r"[/\\]+$", "", path) + return re.sub(r"[^/\\]+$", name, stripped) + + +# --------------------------------------------------------------------------- +# Lane placement +# --------------------------------------------------------------------------- + + +def _placement( + repo_root: str, + lane_key: str, + lane_label: str, + lane_path: str, + is_main: bool, + is_kanban: bool, +) -> dict: + return { + "repo_key": repo_root, + "repo_label": base_name(repo_root) or repo_root, + "repo_path": repo_root, + "lane_key": lane_key, + "lane_label": lane_label, + "lane_path": lane_path, + "is_main": is_main, + "is_kanban": is_kanban, + } + + +def _place_by_heuristic(path: str) -> Optional[dict]: + """Path-only fallback when there is no git probe and no persisted root.""" + base = base_name(path) + if not base: + return None + + kanban_dir = kanban_worktree_dir(path) + if kanban_dir: + repo_path = re.sub(r"[/\\]+$", "", _with_base_name(kanban_dir, "")) + return _placement(repo_path, _kanban_lane_id(repo_path), "kanban", kanban_dir, False, True) + + m = re.match(r"^(.+)-wt-(.+)$", base) + if m: + repo_path = _with_base_name(path, m.group(1)) + return _placement(repo_path, path, m.group(2), path, False, False) + + return _placement(path, path, base, path, True, False) + + +def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]: + info = resolve(cwd) if resolve else None + + if info and info.get("repo_root") and info.get("worktree_root"): + repo_root = info["repo_root"] + worktree_root = info["worktree_root"] + is_main = worktree_root == repo_root or bool(info.get("is_main")) + + if is_main: + # Unrecorded branch folds into the one trunk lane, so a repo never + # shows two "main" lanes (recorded "main" + the empty-branch bucket). + b = (branch or "").strip() or DEFAULT_BRANCH_LABEL + return _placement(repo_root, _branch_lane_id(repo_root, b), b, repo_root, True, False) + + kanban_dir = kanban_worktree_dir(worktree_root) + if kanban_dir: + return _placement(repo_root, _kanban_lane_id(repo_root), "kanban", kanban_dir, False, True) + + label = base_name(worktree_root) or worktree_root + return _placement(repo_root, worktree_root, label, worktree_root, False, False) + + # No live probe: trust the backend-persisted root (group by it, split main by + # the session's recorded branch). Kanban tasks still collapse by path shape. + if persisted_root: + kanban_dir = kanban_worktree_dir(cwd) + if kanban_dir: + return _placement(persisted_root, _kanban_lane_id(persisted_root), "kanban", kanban_dir, False, True) + b = (branch or "").strip() or DEFAULT_BRANCH_LABEL + return _placement(persisted_root, _branch_lane_id(persisted_root, b), b, persisted_root, True, False) + + return _place_by_heuristic(cwd) + + +def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str: + """The COMMON repo root a session belongs to (folds linked worktrees).""" + cwd = (session.get("cwd") or "").strip() + if cwd and resolve: + info = resolve(cwd) + if info and info.get("repo_root"): + return info["repo_root"] + return (session.get("git_repo_root") or "").strip() + + +# --------------------------------------------------------------------------- +# Ordering + label disambiguation (parity with the old client tree) +# --------------------------------------------------------------------------- + + +def _lane_sort_key(group: dict) -> tuple: + # Trunk pins to the top; the kanban aggregate sinks to the bottom; the rest + # (branches + linked worktrees) sort by most-recent activity, then label. + is_trunk = bool(group.get("isMain")) and group["label"].lower() in _TRUNK_BRANCHES + is_kanban = bool(group.get("isKanban")) + activity = max((_session_time(s) for s in group.get("sessions") or []), default=0.0) + return ( + 0 if is_trunk else 1, + 1 if is_kanban else 0, + -activity, + group["label"].lower(), + ) + + +def _sort_lanes(groups: list[dict]) -> list[dict]: + return sorted(groups, key=_lane_sort_key) + + +def _disambiguate_labels(items: list[dict]) -> None: + """Grow colliding basenames into path-prefixed labels (in place).""" + by_label: dict[str, list[dict]] = {} + for item in items: + by_label.setdefault(item["label"], []).append(item) + + for bucket in by_label.values(): + pathed = [g for g in bucket if g.get("path")] + if len(pathed) < 2: + continue + + parents = {id(g): _segments(g["path"])[:-1] for g in pathed} + max_depth = max(len(p) for p in parents.values()) + depth = 1 + while depth <= max_depth: + counts: dict[str, int] = {} + for g in pathed: + segs = parents[id(g)] + prefix = "/".join(segs[-depth:]) if depth else "" + base = base_name(g["path"]) or g["path"] + g["label"] = f"{prefix}/{base}" if prefix else base + counts[g["label"]] = counts.get(g["label"], 0) + 1 + if all(c == 1 for c in counts.values()): + break + depth += 1 + + +# --------------------------------------------------------------------------- +# Repo subtree assembly +# --------------------------------------------------------------------------- + + +def _session_time(session: dict) -> float: + return float(session.get("last_active") or session.get("started_at") or 0) + + +def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool) -> list[dict]: + """Build the ``repo -> lane -> sessions`` subtree for a set of sessions.""" + lanes: dict[str, dict] = {} # lane_key -> {group, repo_key, repo_label, repo_path} + + for session in sessions: + cwd = (session.get("cwd") or "").strip() + if not cwd: + continue + + placement = _place( + cwd, + (session.get("git_branch") or "").strip(), + resolve, + (session.get("git_repo_root") or "").strip(), + ) + if not placement: + continue + + entry = lanes.get(placement["lane_key"]) + if entry is None: + entry = { + "group": { + "id": placement["lane_key"], + "label": placement["lane_label"], + "path": placement["lane_path"], + "isMain": placement["is_main"], + "isKanban": placement["is_kanban"], + "sessions": [], + }, + "repo_key": placement["repo_key"], + "repo_label": placement["repo_label"], + "repo_path": placement["repo_path"], + } + lanes[placement["lane_key"]] = entry + entry["group"]["sessions"].append(session) + + repos: dict[str, dict] = {} + for entry in lanes.values(): + group = entry["group"] + group["sessions"].sort(key=_session_time, reverse=True) + count = len(group["sessions"]) + if not hydrate: + group["sessions"] = [] + + repo = repos.get(entry["repo_key"]) + if repo is None: + repo = { + "id": entry["repo_key"], + "label": entry["repo_label"], + "path": entry["repo_path"], + "groups": [], + "sessionCount": 0, + } + repos[entry["repo_key"]] = repo + repo["groups"].append(group) + repo["sessionCount"] += count + + repo_list = list(repos.values()) + for repo in repo_list: + repo["groups"] = _sort_lanes(repo["groups"]) + _disambiguate_labels(repo["groups"]) + _disambiguate_labels(repo_list) + return repo_list + + +def _seed_folder_repos( + repos: list[dict], folders: list[dict], resolve: Optional[Resolve] +) -> list[dict]: + """Ensure every declared project folder shows as a repo, even with 0 sessions. + + A brand-new project (or any project whose sessions haven't loaded yet) has an + empty session-derived ``repos`` list. That breaks two things on the desktop: + the entered-project view renders blank (it early-returns on no repos), and the + optimistic live-session overlay has no lane to drop a freshly-created session + into — so a new session in the project only appears after a full tree refresh. + Seeding each folder as an empty repo fixes both: the overlay matches a new + session's cwd under the folder root, and the drill-in renders a real (if + empty) project body. Folders already covered by a session-derived repo (same + git root) are left untouched. + """ + seen = {r["id"] for r in repos} | {r["path"] for r in repos if r.get("path")} + seeded = list(repos) + + for folder in folders or []: + raw = (folder.get("path") or "").strip() + if not raw: + continue + info = resolve(raw) if resolve else None + root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw) + if not root or root in seen: + continue + seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0}) + seen.add(root) + + if len(seeded) != len(repos): + _disambiguate_labels(seeded) + + return seeded + + +# --------------------------------------------------------------------------- +# Explicit-project ownership +# --------------------------------------------------------------------------- + + +class _FolderIndex: + """Maps a normalized folder path → (owning project, depth), so a session is + matched to its project by walking its cwd's ancestors (O(path depth) dict + lookups) instead of scanning every project × folder per session — the + difference between O(sessions × projects) and O(sessions) at power-user scale. + """ + + def __init__(self, projects: list[dict]) -> None: + self._by_path: dict[str, tuple[dict, int]] = {} + for project in projects: + for folder in project.get("folders") or []: + segs = _segments(folder.get("path") or "") + if not segs: + continue + key = "/".join(segs) + depth = len(segs) + # Deepest folder wins; ties keep the first project (scan order). + existing = self._by_path.get(key) + if existing is None or depth > existing[1]: + self._by_path[key] = (project, depth) + + def match(self, target: str) -> tuple[Optional[dict], int]: + """Owning project for ``target`` by longest ancestor folder, + its depth.""" + segs = _segments(target or "") + # Longest prefix first → deepest (most specific) folder wins. + for end in range(len(segs), 0, -1): + hit = self._by_path.get("/".join(segs[:end])) + if hit: + return hit + return None, -1 + + +def _project_for_path(index: _FolderIndex, target: str) -> Optional[dict]: + return index.match(target)[0] + + +def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]: + cwd = (session.get("cwd") or "").strip() + if not cwd: + return None + repo_root = _session_repo_root(session, resolve) + candidates = [cwd, repo_root] if repo_root and repo_root != cwd else [cwd] + + best: Optional[dict] = None + best_len = -1 + for target in candidates: + match, length = index.match(target) + if match and length > best_len: + best_len = length + best = match + return best + + +# --------------------------------------------------------------------------- +# Public builder +# --------------------------------------------------------------------------- + + +def _project_node( + *, + pid: str, + label: str, + path: Optional[str], + repos: list[dict], + session_count: int, + last_active: float, + preview_sessions: list[dict], + color: Any = None, + icon: Any = None, + is_auto: bool = False, +) -> dict: + return { + "id": pid, + "label": label, + "path": path, + "color": color, + "icon": icon, + "isAuto": is_auto, + "sessionCount": session_count, + "lastActive": last_active, + "repos": repos, + "previewSessions": preview_sessions, + } + + +def build_tree( + projects: list[dict], + sessions: list[dict], + discovered_repos: list[dict], + resolve: Optional[Resolve] = None, + *, + preview_limit: int = 3, + hydrate: bool = False, + is_junk_root: Optional[Callable[[str], bool]] = None, +) -> dict: + """Build the authoritative project tree. + + ``projects`` are ``projects_db.Project.to_dict()`` shapes (non-archived). + ``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``, + ``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``). + ``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``. + ``is_junk_root`` flags roots that must never become an AUTO project (the + bare home dir, the HERMES_HOME subtree) — their sessions fall through to the + flat Recents list. User-created projects are honored regardless. + + Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When + ``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but + every count is preserved and each project carries up to ``preview_limit`` + ``previewSessions``. When True (drill-in), lanes carry full session rows. + """ + active_projects = [p for p in projects if not p.get("archived")] + _junk = is_junk_root or (lambda _root: False) + folder_index = _FolderIndex(active_projects) + + by_project: dict[str, list[dict]] = {} + unowned: list[dict] = [] + for session in sessions: + owner = _project_for_session(session, folder_index, resolve) + if owner: + by_project.setdefault(owner["id"], []).append(session) + else: + unowned.append(session) + + scoped_ids: list[str] = [] + + def _previews(project_sessions: list[dict]) -> list[dict]: + if preview_limit <= 0: + return [] + ordered = sorted(project_sessions, key=_session_time, reverse=True) + return ordered[:preview_limit] + + def _last_active(project_sessions: list[dict]) -> float: + return max((_session_time(s) for s in project_sessions), default=0.0) + + result: list[dict] = [] + + # Tier 1: explicit, user-created projects (always shown, even with 0 sessions). + for project in active_projects: + psessions = by_project.get(project["id"], []) + scoped_ids.extend(s["id"] for s in psessions if s.get("id")) + repos = _seed_folder_repos( + _build_repos(psessions, resolve, hydrate), project.get("folders") or [], resolve + ) + result.append( + _project_node( + pid=project["id"], + label=project.get("name") or project["id"], + path=project.get("primary_path"), + color=project.get("color"), + icon=project.get("icon"), + repos=repos, + session_count=len(psessions), + last_active=_last_active(psessions), + preview_sessions=_previews(psessions), + ) + ) + + # Tier 2: auto projects from leftover sessions, one per common git repo root. + by_repo: dict[str, list[dict]] = {} + for session in unowned: + root = _session_repo_root(session, resolve) + if root: + by_repo.setdefault(root, []).append(session) + + seen: set[str] = set() + for repo_root, repo_sessions in by_repo.items(): + # The home dir / HERMES_HOME subtree is config + state, never a project; + # its sessions stay loose in Recents (not scoped to a phantom project). + if _junk(repo_root): + continue + repos = _build_repos(repo_sessions, resolve, hydrate) + repo_node = next((r for r in repos if r["id"] == repo_root or r["path"] == repo_root), None) + if repo_node is None: + continue + seen.add(repo_root) + scoped_ids.extend(s["id"] for s in repo_sessions if s.get("id")) + result.append( + _project_node( + pid=repo_root, + label=base_name(repo_root) or repo_root, + path=repo_root, + repos=repos, + session_count=repo_node["sessionCount"], + last_active=_last_active(repo_sessions), + preview_sessions=_previews(repo_sessions), + is_auto=True, + ) + ) + + # Tier 3: repos discovered from full history / disk scan with no loaded + # sessions, folded to their common root and not owned by an explicit project. + for repo in discovered_repos or []: + raw_root = (repo.get("root") or "").strip() + if not raw_root: + continue + info = resolve(raw_root) if resolve else None + root = (info or {}).get("repo_root") or raw_root + if root in seen or _junk(root) or _project_for_path(folder_index, root): + continue + seen.add(root) + label = repo.get("label") or base_name(root) or root + result.append( + _project_node( + pid=root, + label=label, + path=root, + repos=[{"id": root, "label": label, "path": root, "groups": [], "sessionCount": 0}], + session_count=int(repo.get("sessions") or 0), + last_active=float(repo.get("last_active") or 0), + preview_sessions=[], + is_auto=True, + ) + ) + + # Auto projects are labelled by repo basename, which can collide (two "app" + # repos in different parents). Grow path prefixes so each is distinct. + # Explicit projects keep their user-chosen names untouched. + _disambiguate_labels([p for p in result if p.get("isAuto")]) + + return {"projects": result, "scoped_session_ids": scoped_ids} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 24299a82ceb..159f23ddddc 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -25,6 +25,7 @@ from hermes_constants import ( ) from hermes_cli.env_loader import load_hermes_dotenv from utils import is_truthy_value +from tui_gateway import git_probe from tui_gateway.transport import ( StdioTransport, Transport, @@ -191,6 +192,11 @@ _LONG_HANDLERS = frozenset( "pet.select", "pet.thumb", "plugins.manage", + "projects.discover_repos", + "projects.record_repos", + "projects.for_cwd", + "projects.tree", + "projects.project_sessions", "session.branch", "session.compress", "session.resume", @@ -1363,31 +1369,14 @@ def _terminal_task_cwd(session: dict | None) -> str: return _session_cwd(session) -def _git_branch_for_cwd(cwd: str) -> str: - try: - result = subprocess.run( - ["git", "-C", cwd, "branch", "--show-current"], - capture_output=True, - text=True, - timeout=1.5, - check=False, - stdin=subprocess.DEVNULL, - ) - if result.returncode == 0: - branch = result.stdout.strip() - if branch: - return branch - head = subprocess.run( - ["git", "-C", cwd, "rev-parse", "--short", "HEAD"], - capture_output=True, - text=True, - timeout=1.5, - check=False, - stdin=subprocess.DEVNULL, - ) - return head.stdout.strip() if head.returncode == 0 else "" - except Exception: - return "" +# Git working-tree probing (run git, resolve roots, fold worktrees) lives in a +# focused, single-flight-cached module; these stay as the in-server names every +# call site already uses. +_git = git_probe.run_git +_git_branch_for_cwd = git_probe.branch +_git_repo_root_for_cwd = git_probe.repo_root +_git_common_repo_root_for_cwd = git_probe.common_repo_root +_resolve_cwd_git = git_probe.resolve def _session_cwd(session: dict | None) -> str: @@ -1396,6 +1385,75 @@ def _session_cwd(session: dict | None) -> str: return _completion_cwd() +def _heal_dead_cwd(cwd: str) -> str: + """Resolve a session cwd that points at a now-deleted directory. + + A session anchored to a linked worktree (``/.worktrees/``) keeps + that path after the worktree is removed (branch merged, `git worktree + remove`, etc). The literal dir is gone, so a probe of it returns nothing and + the composer shows no branch — while the sidebar still folds the path up to + the repo's main lane. Heal the mismatch: walk up to the first existing + ancestor, then resolve its common git root, so a dead-worktree cwd collapses + to the live repo root (and its real current branch). + + Only meaningful for local backends; a remote/SSH cwd may legitimately not + exist on the host, so callers must skip healing there. + """ + raw = (cwd or "").strip() + if not raw or os.path.isdir(raw): + return raw + + probe = raw + # Climb to the first ancestor that still exists on disk. + for _ in range(64): + parent = os.path.dirname(probe) + if not parent or parent == probe: + break + probe = parent + if os.path.isdir(probe): + break + + if not os.path.isdir(probe): + return raw + + try: + root = _git_common_repo_root_for_cwd(probe) or _git_repo_root_for_cwd(probe) + except Exception: + root = "" + + return root or probe + + +def _is_local_terminal_backend() -> bool: + backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower() + return not backend or backend == "local" + + +def _display_session_cwd(session: dict | None) -> str: + """Session cwd for display/probe surfaces, healed past deleted worktrees. + + Persists the healed value back to the session row (best-effort, local only) + so the next load is already coherent and the sidebar lane stops showing a + session pinned to a vanished path. + """ + cwd = _session_cwd(session) + if not _is_local_terminal_backend(): + return cwd + + healed = _heal_dead_cwd(cwd) + if healed and healed != cwd and session is not None: + session["cwd"] = healed + try: + with _session_db(session) as db: + if db is not None: + db.update_session_cwd(session.get("session_key", ""), healed) + except Exception: + logger.debug("failed to persist healed session cwd", exc_info=True) + _persist_session_git_meta(session, healed) + + return healed + + def _session_source(session: dict | None) -> str: if session: source = str(session.get("source") or "").strip() @@ -1500,12 +1558,19 @@ def _ensure_session_db_row(session: dict) -> None: model_config["reasoning_config"] = reasoning if tier := session.get("create_service_tier_override"): model_config["service_tier"] = tier + # Branch lineage: stamp the same ``_branched_from`` marker the TUI /branch + # uses so list_sessions_rich keeps the branch listed and the desktop sidebar + # can nest it under its parent. + parent_session_id = session.get("parent_session_id") or None + if parent_session_id: + model_config["_branched_from"] = parent_session_id try: db.create_session( key, source=_session_source(session), model=row_model, model_config=model_config or None, + parent_session_id=parent_session_id, cwd=_session_cwd(session) if session.get("explicit_cwd") else None, ) except Exception: @@ -1518,6 +1583,35 @@ def _ensure_session_db_row(session: dict) -> None: pass +def _persist_branch_seed(session: dict) -> None: + """First-turn persist of a branch's copied transcript. + + A branch is a draft until its first submit: the parent's messages live only + in ``session["history"]`` (they ride into the agent as ``conversation_history``, + which ``_flush_messages_to_session_db`` skips by identity). Without this the + branch row would resume missing its pre-branch context. Runs once; the row + + parent link are written by ``_ensure_session_db_row`` just before this. + """ + if not session.get("parent_session_id") or session.get("_branch_seed_persisted"): + return + key = session.get("session_key") + if not key: + return + with session["history_lock"]: + seed = [dict(msg) for msg in (session.get("history") or [])] + if not seed: + return + with _session_db(session) as db: + if db is None: + return + try: + for msg in seed: + db.append_message(session_id=key, role=msg.get("role", "user"), content=msg.get("content")) + session["_branch_seed_persisted"] = True + except Exception: + logger.debug("branch seed persist failed", exc_info=True) + + @contextlib.contextmanager def _session_db(session: dict): """Yield the SessionDB that owns this session's row (profile-aware). @@ -1546,6 +1640,41 @@ def _session_db(session: dict): db.close() +def _persist_session_git_meta(session: dict, cwd: str) -> None: + """Resolve + persist a session's git branch / repo root WITHOUT blocking. + + Branch and root come from ``git`` subprocess probes; running them inline on + the session-init / cwd-set path would stall startup whenever ``cwd`` is slow + or on an unreachable mount. Run them on a short-lived daemon thread instead + and persist via the same profile-aware db the caller writes ``cwd`` to. + + Best-effort: ``cwd`` itself is persisted synchronously by the caller, so a + probe failure just leaves these enrichment columns unset (the project tree + falls back to its live resolver / lazy backfill). Daemon, so a mid-flight + probe never delays gateway shutdown. + """ + session_key = session.get("session_key", "") + if not session_key or not cwd: + return + # Snapshot the routing fields now; the live session dict may be gone by the + # time the thread runs. `_session_db` reopens the profile-correct db inside. + db_session = {"session_key": session_key, "profile_home": session.get("profile_home")} + + def _run() -> None: + try: + branch = _git_branch_for_cwd(cwd) + root = _git_common_repo_root_for_cwd(cwd) + if not (branch or root): + return + with _session_db(db_session) as db: + if db is not None: + db.update_session_cwd(session_key, cwd, branch, root) + except Exception: + logger.debug("failed to persist session git metadata", exc_info=True) + + threading.Thread(target=_run, name="git-meta", daemon=True).start() + + def _set_session_cwd(session: dict, cwd: str) -> str: resolved = os.path.abspath(os.path.expanduser(str(cwd))) if not os.path.isdir(resolved): @@ -1561,6 +1690,8 @@ def _set_session_cwd(session: dict, cwd: str) -> str: db.update_session_cwd(session.get("session_key", ""), resolved) except Exception: logger.debug("failed to persist session cwd", exc_info=True) + # Branch/repo-root probes are git subprocesses — capture them off the hot path. + _persist_session_git_meta(session, resolved) try: from tools.terminal_tool import cleanup_vm @@ -2240,7 +2371,11 @@ def _load_enabled_toolsets() -> list[str] | None: selection = coding_selection(platform="tui") if selection is not None: - return selection + # Fold in `project` here too: this is a GUI-only resolver, and + # the focus-mode coding posture returns before the fallback path + # that normally adds it — without this the desktop loses the + # project tools exactly when sitting in a repo (see below). + return sorted({*selection, "project"}) except Exception: pass @@ -2346,12 +2481,18 @@ def _load_enabled_toolsets() -> list[str] | None: # list without baking in implicit MCP defaults. Using the wrong # variant at agent creation time makes MCP tools silently missing # from the TUI. See PR #3252 for the original design split. - enabled = sorted( - _get_platform_tools(cfg, "cli", include_default_mcp_servers=True) - ) + enabled = _get_platform_tools(cfg, "cli", include_default_mcp_servers=True) if fallback_notice is not None: print(fallback_notice, file=sys.stderr, flush=True) - return enabled or None + if not enabled: + return None + # The desktop Project tools are off _HERMES_CORE_TOOLS (every other + # platform would carry their schema for nothing), so the platform + # recovery above — which keys off hermes-cli's tool universe — can't + # surface them. This resolver runs ONLY in the desktop/TUI gateway, so + # folding in the `project` toolset here is the gate that exposes them on + # exactly the surface that can follow a project move. + return sorted(enabled | {"project"}) except Exception: if fallback_notice is not None: print( @@ -2914,7 +3055,7 @@ def _session_info(agent, session: dict | None = None) -> dict: if candidate.get("agent") is agent: session = candidate break - cwd = _session_cwd(session) + cwd = _display_session_cwd(session) session_key = str( (session or {}).get("session_key") or getattr(agent, "session_id", "") or "" ) @@ -3466,11 +3607,67 @@ def _agent_cbs(sid: str) -> dict: } +def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None: + """Intentional workspace move from the project_* tools: re-anchor the live + session's cwd to the chosen project's folder and push session.info so the + desktop follows (refresh tree + scope into the project). This is the ONLY + auto-cwd path — driven by an explicit tool call, never a terminal `cd`.""" + if not path: + return + + # The tool's task_id is the durable session_key, but _sessions is keyed by a + # short sid uuid (and the desktop routes events by that sid). Resolve it. + key = str(task_id or "") + sid = "" + session = None + with _sessions_lock: + if key in _sessions: + sid, session = key, _sessions[key] + else: + for cand_sid, cand in _sessions.items(): + if cand.get("session_key") == key or getattr(cand.get("agent"), "session_id", None) == key: + sid, session = cand_sid, cand + break + + if session is None: + return + + resolved = os.path.abspath(os.path.expanduser(str(path))) + if not os.path.isdir(resolved): + return + + session["cwd"] = resolved + session["explicit_cwd"] = True + _register_session_cwd(session) + + with _session_db(session) as db: + if db is not None: + try: + db.update_session_cwd(session.get("session_key", ""), resolved) + except Exception: + logger.debug("failed to persist project workspace cwd", exc_info=True) + + _persist_session_git_meta(session, resolved) + + try: + agent = session.get("agent") + info = ( + _session_info(agent, session) + if agent is not None + else {"cwd": resolved, "branch": _git_branch_for_cwd(resolved), "lazy": True} + ) + _emit("session.info", sid, info) + except Exception: + logger.debug("failed to emit session.info after project workspace move", exc_info=True) + + def _wire_callbacks(sid: str): from tools.terminal_tool import set_sudo_password_callback from tools.skills_tool import set_secret_capture_callback + from tools.project_tools import set_project_workspace_callback set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120)) + set_project_workspace_callback(_apply_project_workspace) def secret_cb(env_var, prompt, metadata=None): pl = {"prompt": prompt, "env_var": env_var} @@ -4119,7 +4316,10 @@ def _init_session( _sessions[sid]["cwd"] = row["cwd"] else: try: - db.update_session_cwd(key, _sessions[sid]["cwd"]) + _cwd = _sessions[sid]["cwd"] + db.update_session_cwd(key, _cwd) + # git branch/root probes run off the hot path (see _set_session_cwd). + _persist_session_git_meta(_sessions[sid], _cwd) except Exception: logger.debug("failed to persist resumed session cwd", exc_info=True) _register_session_cwd(_sessions[sid]) @@ -4592,6 +4792,10 @@ def _(rid, params: dict) -> dict: cols = int(params.get("cols", 80)) history = _coerce_seed_history(params.get("messages")) title = str(params.get("title") or "").strip() + # When set, this is a branch: the new chat copies an existing conversation's + # history and links back to it so list_sessions_rich keeps it visible and the + # sidebar can nest it under its parent. Mirrors the TUI /branch marker. + parent_session_id = str(params.get("parent_session_id") or "").strip() or None # Did the client pick a workspace, or are we falling back to the gateway's # launch directory? Only an explicit choice is persisted as the session's # workspace (see _ensure_session_db_row); otherwise it lands in "No @@ -4663,6 +4867,7 @@ def _(rid, params: dict) -> dict: "model_override": session_model_override, "create_reasoning_override": create_reasoning_override, "create_service_tier_override": create_service_tier_override, + "parent_session_id": parent_session_id, "pending_title": title or None, "profile_home": str(profile_home) if profile_home is not None else None, "running": False, @@ -4675,6 +4880,7 @@ def _(rid, params: dict) -> dict: "transport": current_transport() or _stdio_transport, } _register_session_cwd(_sessions[sid]) + # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop # launch (and every "New agent" / draft) opens a session here just to paint # the composer, so eagerly creating a row left an "Untitled" empty session @@ -7469,8 +7675,28 @@ def _(rid, params: dict) -> dict: session, err = _sess(params, rid) if err: return err - if hasattr(session["agent"], "interrupt"): + # Safety net: if the turn's run thread is already gone but `running` stayed + # stuck (a crash/desync that skipped the run loop's `finally`), force-clear it + # so the session can't be permanently bricked at 4009 "session busy" — every + # send/restore/resume would otherwise reject until a full backend restart. + # A genuinely live turn is left alone: its cooperative interrupt + `finally` + # release `running` the normal way; clearing it here would let a second turn + # race the first on the same session. + run_thread = session.get("_run_thread") + run_thread_alive = run_thread is not None and run_thread.is_alive() + should_interrupt = bool(session.get("running")) and run_thread_alive + if should_interrupt and hasattr(session["agent"], "interrupt"): session["agent"].interrupt() + if not run_thread_alive: + with session["history_lock"]: + if session.get("running"): + session["running"] = False + _clear_inflight_turn(session) + + # Stop = stop the TURN (cooperative interrupt above also kills the in-flight + # foreground subprocess). Background processes the agent started (dev servers, + # watchers) are intentionally left running — kill those individually with the + # "x" on the task row (process.kill). Don't reap them here. # Scope the pending-prompt release to THIS session. A global # _clear_pending() would collaterally cancel clarify/sudo/secret # prompts on unrelated sessions sharing the same tui_gateway @@ -7796,6 +8022,9 @@ def _(rid, params: dict) -> dict: # Persist the DB row lazily, now that the user has actually sent a message. _ensure_session_db_row(session) + # A branch becomes real here: copy its parent's transcript into the row so it + # resumes with full context (the agent won't persist the seed itself). + _persist_branch_seed(session) _start_agent_build(sid, session) def run_after_agent_ready() -> None: @@ -7816,7 +8045,11 @@ def _(rid, params: dict) -> dict: return _run_prompt_submit(rid, sid, session, text) - threading.Thread(target=run_after_agent_ready, daemon=True).start() + run_thread = threading.Thread(target=run_after_agent_ready, daemon=True) + # Keep a handle so session.interrupt can tell a live turn from a stuck + # `running` flag (a turn that died without clearing it) and recover the latter. + session["_run_thread"] = run_thread + run_thread.start() return _ok(rid, {"status": "streaming"}) @@ -8025,6 +8258,11 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: if not isinstance(session.get("inflight_turn"), dict): _start_inflight_turn(session, text) agent = session["agent"] + if hasattr(agent, "clear_interrupt"): + try: + agent.clear_interrupt() + except Exception: + pass _emit("message.start", sid) def run(): @@ -8329,12 +8567,19 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: try: from agent.title_generator import maybe_auto_title + _title_key = session.get("session_key") or sid maybe_auto_title( _get_db(), - session.get("session_key") or sid, + _title_key, text, raw, session.get("history", []), + # Push the generated title live so the sidebar renames + # without waiting for the next list refresh (the titler + # runs async, after this turn's refresh already fired). + title_callback=lambda t, _k=_title_key: _emit( + "session.title", sid, {"session_id": _k, "title": t} + ), ) except Exception: pass @@ -9803,6 +10048,438 @@ def _(rid, params: dict) -> dict: return _err(rid, 4002, f"unknown config key: {key}") +# --------------------------------------------------------------------------- +# Projects — first-class, per-profile, multi-folder workspaces +# --------------------------------------------------------------------------- + + +# JSON-RPC error codes for the projects surface. +_E_PROJECTS = 5061 # generic failure +_E_NO_PROJECT = 5062 # id resolved to nothing +_E_PROJECT_ARG = 5063 # invalid argument (e.g. bad name/slug) + + +class _NoProject(Exception): + """Raised inside a projects handler when ``params['id']`` resolves to None.""" + + +def _projects_payload(conn) -> dict: + from hermes_cli import projects_db as pdb + + return { + "projects": [p.to_dict() for p in pdb.list_projects(conn, include_archived=True)], + "active_id": pdb.get_active_id(conn), + } + + +def _projects_method(name: str): + """Register a projects RPC, injecting (pdb, conn) and unifying error mapping. + + Every project CRUD handler opened the per-profile DB, mapped a missing id to + 5062, bad args to 5063, and everything else to 5061. This collapses that + boilerplate so each handler is just its one meaningful operation. + """ + + def decorator(fn): + @method(name) + def handler(rid, params: dict) -> dict: + try: + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + return fn(rid, params, pdb, conn) + except _NoProject: + return _err(rid, _E_NO_PROJECT, "no such project") + except ValueError as e: + return _err(rid, _E_PROJECT_ARG, str(e)) + except Exception as e: + return _err(rid, _E_PROJECTS, str(e)) + + return handler + + return decorator + + +def _require_project(pdb, conn, params: dict): + """The project named by ``params['id']`` (or raise ``_NoProject``).""" + proj = pdb.get_project(conn, str(params.get("id") or "")) + if proj is None: + raise _NoProject + return proj + + +@_projects_method("projects.list") +def _(rid, params, pdb, conn) -> dict: + return _ok(rid, _projects_payload(conn)) + + +@_projects_method("projects.get") +def _(rid, params, pdb, conn) -> dict: + return _ok(rid, {"project": _require_project(pdb, conn, params).to_dict()}) + + +@_projects_method("projects.create") +def _(rid, params, pdb, conn) -> dict: + pid = pdb.create_project( + conn, + name=str(params.get("name") or ""), + slug=params.get("slug"), + folders=params.get("folders") or [], + primary_path=params.get("primary_path"), + description=params.get("description"), + icon=params.get("icon"), + color=params.get("color"), + board_slug=params.get("board_slug"), + ) + if params.get("use"): + pdb.set_active(conn, pid) + proj = pdb.get_project(conn, pid) + return _ok(rid, {"project": proj.to_dict() if proj else None}) + + +@_projects_method("projects.update") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.update_project( + conn, + proj.id, + name=params.get("name"), + description=params.get("description"), + icon=params.get("icon"), + color=params.get("color"), + board_slug=params.get("board_slug"), + ) + return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()}) + + +@_projects_method("projects.add_folder") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.add_folder( + conn, + proj.id, + str(params.get("path") or ""), + label=params.get("label"), + is_primary=bool(params.get("is_primary")), + ) + return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()}) + + +@_projects_method("projects.remove_folder") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.remove_folder(conn, proj.id, str(params.get("path") or "")) + return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()}) + + +@_projects_method("projects.set_primary") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.set_primary(conn, proj.id, str(params.get("path") or "")) + return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()}) + + +@_projects_method("projects.archive") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + (pdb.restore_project if params.get("restore") else pdb.archive_project)(conn, proj.id) + return _ok(rid, _projects_payload(conn)) + + +@_projects_method("projects.delete") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.delete_project(conn, proj.id) + return _ok(rid, _projects_payload(conn)) + + +@_projects_method("projects.set_active") +def _(rid, params, pdb, conn) -> dict: + pdb.set_active(conn, _require_project(pdb, conn, params).id if params.get("id") else None) + return _ok(rid, {"active_id": pdb.get_active_id(conn)}) + + +@_projects_method("projects.for_cwd") +def _(rid, params, pdb, conn) -> dict: + cwd = _completion_cwd({"cwd": str(params.get("cwd") or "").strip()} if params.get("cwd") else {}) + proj = pdb.project_for_path(conn, cwd) + return _ok(rid, {"project": proj.to_dict() if proj else None, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)}) + + +def _is_repo_junk(root: str) -> bool: + """A git root we never auto-surface as a project: the bare home dir or + anything under HERMES_HOME (~/.hermes by default) — config/sessions/skills, + not a workspace. User-created projects pointing there are still honored.""" + if not root: + return True + + from hermes_constants import get_hermes_home + + real = os.path.realpath(root) + home = os.path.realpath(os.path.expanduser("~")) + hermes_home = os.path.realpath(str(get_hermes_home())) + + return real == home or real == hermes_home or real.startswith(hermes_home + os.sep) + + +def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]: + """Merge filesystem-scanned repos (cached) with session-derived repo roots. + + Repo-first: the disk scan (persisted by `projects.record_repos`) surfaces + repos even with zero hermes sessions. Session-derived roots cover repos + outside the scan roots. Both are junk-filtered (hermes home subtree + bare + home) and carry their session totals for the overview. + + ``conn`` reuses an already-open projects.db connection (the tree path holds + one); ``backfill`` persists resolved roots back onto session rows — kept off + the per-turn tree path (grouping uses the live git resolver regardless) and + done only on the explicit discover/record refresh. + """ + _is_junk = _is_repo_junk + repos: dict[str, dict] = {} + + def _agg(root: str) -> dict: + return repos.setdefault(root, {"root": root, "label": "", "sessions": 0, "last_active": 0.0}) + + # Session-derived roots (common repo root, folding worktrees; cached) + + # backfill the column so persisted git_repo_root matches the tree grouping. + cwd_rows = list(db.distinct_session_cwds()) + # Warm the per-cwd git probes in parallel so a cold first paint doesn't + # serialize one subprocess per distinct cwd before this loop reads the cache. + git_probe.warm_roots(str(r.get("cwd") or "") for r in cwd_rows) + cwd_to_root: dict[str, str] = {} + for row in cwd_rows: + cwd = str(row.get("cwd") or "") + root = _git_common_repo_root_for_cwd(cwd) + if not root: + continue + cwd_to_root[cwd] = root + if _is_junk(root): + continue + agg = _agg(root) + agg["sessions"] += int(row.get("sessions") or 0) + agg["last_active"] = max(agg["last_active"], float(row.get("last_active") or 0)) + + if backfill: + try: + db.backfill_repo_roots(cwd_to_root) + except Exception: + logger.debug("failed to backfill repo roots", exc_info=True) + + # Filesystem-scanned roots from the cache (may have zero sessions). Reuse the + # caller's projects.db connection when given, else open a short-lived one. + try: + from hermes_cli import projects_db as pdb + + def _read(c) -> None: + for entry in pdb.list_discovered_repos(c): + root = str(entry.get("root") or "") + if not root or _is_junk(root): + continue + agg = _agg(root) + if entry.get("label"): + agg["label"] = entry["label"] + agg["last_active"] = max(agg["last_active"], float(entry.get("last_seen") or 0)) + + if conn is not None: + _read(conn) + else: + with pdb.connect_closing() as own: + _read(own) + except Exception: + logger.debug("failed to read discovered repo cache", exc_info=True) + + out = sorted(repos.values(), key=lambda r: r["last_active"], reverse=True) + for r in out: + r["label"] = r["label"] or os.path.basename(r["root"].rstrip("/\\")) or r["root"] + return out + + +@method("projects.discover_repos") +def _(rid, params: dict) -> dict: + """Repos for the desktop overview: scanned-from-disk (cached) ∪ session-derived.""" + try: + db = _get_db() + if db is None: + return _ok(rid, {"repos": []}) + return _ok(rid, {"repos": _discover_repos_payload(db)}) + except Exception as e: + return _err(rid, 5061, str(e)) + + +@method("projects.record_repos") +def _(rid, params: dict) -> dict: + """Persist git repo roots found by the client's filesystem scan, then return + the merged repo list. The native crawl runs on the desktop (local fs); this + caches the result so later reads are instant instead of re-walking disk.""" + try: + from hermes_cli import projects_db as pdb + + pairs: list[tuple[str, str | None]] = [] + for item in params.get("repos") or []: + if isinstance(item, str): + pairs.append((item, None)) + elif isinstance(item, dict) and item.get("root"): + pairs.append((str(item["root"]), item.get("label"))) + + with pdb.connect_closing() as conn: + pdb.record_discovered_repos(conn, pairs, replace=True) + + db = _get_db() + return _ok(rid, {"repos": _discover_repos_payload(db) if db is not None else []}) + except Exception as e: + return _err(rid, 5061, str(e)) + + +# Sources excluded from the project tree: cron runs and tool/subagent children +# are not user conversations. Subagent/compression children are already dropped +# by list_sessions_rich(include_children=False); cron has its own section. +_PROJECT_TREE_EXCLUDED_SOURCES = ["cron"] + + +def _project_tree_row(r: dict) -> dict: + """Project a SessionDB row to the minimal shape the sidebar renders. + + Keeps the fields the grouping needs (cwd / git_branch / git_repo_root) plus + everything ``SidebarSessionRow`` reads, and drops the heavy columns + (system_prompt, model_config, ...) so the tree payload stays lean. + """ + return { + "id": r.get("id"), + "_lineage_root_id": r.get("_lineage_root_id"), + # The sidebar nests branch/fork sessions under their parent + # (flattenSessionsWithBranches keys on this); without it, lane rows can't + # draw the └─ connector the flat Recents list shows. + "parent_session_id": r.get("parent_session_id"), + "title": r.get("title"), + "preview": r.get("preview"), + "started_at": r.get("started_at") or 0, + "ended_at": r.get("ended_at"), + "last_active": r.get("last_active") or r.get("started_at") or 0, + "source": r.get("source"), + "archived": bool(r.get("archived")), + "message_count": r.get("message_count") or 0, + "tool_call_count": r.get("tool_call_count") or 0, + "input_tokens": r.get("input_tokens") or 0, + "output_tokens": r.get("output_tokens") or 0, + "model": r.get("model"), + "is_active": False, + "cwd": r.get("cwd"), + "git_branch": r.get("git_branch"), + "git_repo_root": r.get("git_repo_root"), + } + + +def _project_tree_inputs( + db, session_limit: int, *, include_discovered: bool +) -> tuple[list[dict], list[dict], list[dict], str | None]: + """Gather (sessions, projects, discovered_repos, active_id) for build_tree. + + ``include_discovered`` is the zero-session-repo overview tier; the entered + view (drill-in) skips it entirely — it only needs the project it's showing, + which already has sessions — avoiding the distinct-cwd scan + git probes on + that per-turn path. One projects.db connection serves both reads. + """ + rows = db.list_sessions_rich( + limit=session_limit, + offset=0, + order_by_last_active=True, + min_message_count=1, + include_children=False, + exclude_sources=_PROJECT_TREE_EXCLUDED_SOURCES, + include_archived=False, + ) + sessions = [_project_tree_row(r) for r in rows] + # Parallel-warm the git cache so build_tree's resolver reads it instead of + # cold-probing each cwd in sequence (matters on the drill-in path, which + # skips the discovery warm-up below). + git_probe.warm_roots(s["cwd"] for s in sessions if s.get("cwd")) + + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + projects = [p.to_dict() for p in pdb.list_projects(conn)] + active_id = pdb.get_active_id(conn) + # backfill stays off the hot tree path — grouping uses the live resolver. + discovered = _discover_repos_payload(db, conn=conn, backfill=False) if include_discovered else [] + + return sessions, projects, discovered, active_id + + +def _build_project_tree( + db, *, preview_limit: int, hydrate: bool, session_limit: int, include_discovered: bool +) -> tuple[dict, str | None]: + """Gather inputs and run the one authoritative builder. Returns (tree, active_id).""" + from tui_gateway import project_tree + + sessions, projects, discovered, active_id = _project_tree_inputs( + db, session_limit, include_discovered=include_discovered + ) + tree = project_tree.build_tree( + projects, + sessions, + discovered, + _resolve_cwd_git, + preview_limit=preview_limit, + hydrate=hydrate, + is_junk_root=_is_repo_junk, + ) + return tree, active_id + + +@method("projects.tree") +def _(rid, params: dict) -> dict: + """Authoritative project overview: project -> repo -> lane structure with + counts + a few preview sessions per project, plus the flat set of session + ids claimed by any project (so the desktop excludes them from flat Recents). + Lanes carry no session rows here; drill-in uses ``projects.project_sessions``. + """ + try: + db = _get_db() + if db is None: + return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []}) + + tree, active_id = _build_project_tree( + db, + preview_limit=int(params.get("preview_limit") or 3), + hydrate=False, + session_limit=int(params.get("session_limit") or 2000), + include_discovered=True, + ) + return _ok( + rid, + {"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]}, + ) + except Exception as e: + return _err(rid, 5061, str(e)) + + +@method("projects.project_sessions") +def _(rid, params: dict) -> dict: + """Fully hydrated lanes (repo -> lane -> session rows) for one project, + built from the same authoritative grouping as ``projects.tree`` so ids and + membership match exactly. Used when the user enters a project.""" + try: + project_id = str(params.get("project_id") or "") + if not project_id: + return _err(rid, 5063, "project_id required") + + db = _get_db() + if db is None: + return _ok(rid, {"project": None}) + + # Drill-in only needs the entered project (which has sessions), so skip + # the zero-session discovery tier entirely. + tree, _active = _build_project_tree( + db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000), + include_discovered=False, + ) + proj = next((p for p in tree["projects"] if p["id"] == project_id), None) + return _ok(rid, {"project": proj}) + except Exception as e: + return _err(rid, 5061, str(e)) + + @method("config.get") def _(rid, params: dict) -> dict: key = params.get("key", "") From 4ffdedd369c1ee242fe79e43faa1230f46ed3a6d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH 05/19] feat(tools): add project workspace tools --- agent/prompt_builder.py | 5 +- tests/agent/test_system_prompt.py | 21 ++-- tools/project_tools.py | 189 ++++++++++++++++++++++++++++++ toolsets.py | 11 ++ 4 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 tools/project_tools.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 7f1986fbed0..1a87e66cde4 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -243,7 +243,10 @@ KANBAN_GUIDANCE = ( "- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind " "with no `.git`, `git worktree add " "${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then " - "cd there.\n" + "cd there. For a project-linked task the workspace is a fresh " + "`/.worktrees/` and `$HERMES_KANBAN_BRANCH` a deterministic " + "`/` — the main repo is two levels up, so run " + "`git worktree add` from there.\n" "- **Deliverables.** Files a human wants go in " "`kanban_complete(artifacts=[])` (top-level param; paths in " "`metadata` are NOT uploaded). Files must exist at completion.\n" diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index 7c4d252ec79..6ebf2a61960 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -67,11 +67,18 @@ def _stable_prompt(agent): return build_system_prompt_parts(agent)["stable"] +def _init_code_repo(path): + """A git repo that actually holds code — the coding posture requires a source + file (or manifest), not a bare ``.git`` (a prose/notes repo stays general).""" + import subprocess + + subprocess.run(["git", "-C", str(path), "init", "-q"], check=True) + (path / "main.py").write_text("print('hi')\n") + + class TestCodingContextBlock: def test_injected_when_active(self, monkeypatch, tmp_path): - import subprocess - - subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + _init_code_repo(tmp_path) monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) agent = _make_agent(valid_tool_names=["read_file"], platform="cli") stable = _stable_prompt(agent) @@ -79,9 +86,7 @@ class TestCodingContextBlock: assert "Workspace" in stable def test_absent_when_off(self, monkeypatch, tmp_path): - import subprocess - - subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + _init_code_repo(tmp_path) monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) agent = _make_agent(valid_tool_names=["read_file"], platform="cli") # Drive the real path: force the resolved mode to "off" via config. @@ -90,9 +95,7 @@ class TestCodingContextBlock: assert "coding agent" not in stable def test_absent_without_tools(self, monkeypatch, tmp_path): - import subprocess - - subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + _init_code_repo(tmp_path) monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) agent = _make_agent(valid_tool_names=[], platform="cli") assert "coding agent" not in _stable_prompt(agent) diff --git a/tools/project_tools.py b/tools/project_tools.py new file mode 100644 index 00000000000..2b52e3144d6 --- /dev/null +++ b/tools/project_tools.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Project tools — the agent's INTENTIONAL handle on first-class Projects. + +Projects (per-profile ``projects.db``) are the named workspaces the desktop +sidebar groups sessions into. Creating / switching a project is a deliberate act +expressed as explicit tools — never a side effect of a terminal ``cd``. + +Exposed only on GUI sessions: the tools live in the `project` toolset (kept off +``_HERMES_CORE_TOOLS``) which the desktop/TUI gateway folds into its resolved +toolsets, so no CLI/messaging/cron schema carries them. The GUI also wires +``set_project_workspace_callback`` so a create/switch re-anchors the live +session's cwd and the sidebar follows the move; the DB write is the durable part. +""" + +import json +import os +from typing import Callable, Optional + +from tools.registry import registry + +# Set by the GUI gateway (tui_gateway) at session wiring. Receives +# ``(task_id, primary_path, project_name)`` and re-anchors that session's +# workspace + refreshes the sidebar. ``None`` in CLI / messaging contexts — the +# DB write still happens; there's just no live GUI session to move. +_workspace_callback: Optional[Callable[[str, str, str], None]] = None + + +def set_project_workspace_callback(fn: Optional[Callable[[str, str, str], None]]) -> None: + global _workspace_callback + _workspace_callback = fn + + +def _primary_path(proj) -> Optional[str]: + if getattr(proj, "primary_path", None): + return proj.primary_path + for folder in proj.folders: + if folder.is_primary: + return folder.path + return proj.folders[0].path if proj.folders else None + + +def _apply_workspace(task_id: Optional[str], path: Optional[str], name: str) -> None: + cb = _workspace_callback + if cb and task_id and path: + try: + cb(task_id, path, name) + except Exception: + pass + + +def _resolve(conn, token: str): + from hermes_cli import projects_db as pdb + + token = (token or "").strip() + if not token: + return None + projects = pdb.list_projects(conn, include_archived=True) + # Exact id / slug / name first, then case-insensitive slug / name. + for proj in projects: + if token in (proj.id, proj.slug) or proj.name == token: + return proj + low = token.lower() + for proj in projects: + if proj.slug.lower() == low or proj.name.lower() == low: + return proj + return None + + +def project_list(task_id: Optional[str] = None) -> str: + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + active = pdb.get_active_id(conn) + projects = pdb.list_projects(conn) + + return json.dumps({ + "active_id": active, + "projects": [ + { + "id": p.id, + "slug": p.slug, + "name": p.name, + "primary_path": _primary_path(p), + "active": p.id == active, + } + for p in projects + ], + }) + + +def project_create(name: str, path: Optional[str] = None, task_id: Optional[str] = None) -> str: + name = (name or "").strip() + if not name: + return json.dumps({"success": False, "error": "name is required"}) + + from hermes_cli import projects_db as pdb + + folder = (path or "").strip() + if folder: + folder = os.path.abspath(os.path.expanduser(folder)) + + try: + with pdb.connect_closing() as conn: + pid = pdb.create_project(conn, name=name, folders=[folder] if folder else [], primary_path=folder or None) + pdb.set_active(conn, pid) + proj = pdb.get_project(conn, pid) + except ValueError as exc: + return json.dumps({"success": False, "error": str(exc)}) + + if proj is None: + return json.dumps({"success": False, "error": "project vanished after create"}) + + primary = _primary_path(proj) + _apply_workspace(task_id, primary, proj.name) + + return json.dumps({"success": True, "id": proj.id, "slug": proj.slug, "name": proj.name, "primary_path": primary}) + + +def project_switch(project: str, task_id: Optional[str] = None) -> str: + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + proj = _resolve(conn, project) + if proj is None: + return json.dumps({"success": False, "error": f"no project matching '{project}'"}) + pdb.set_active(conn, proj.id) + + primary = _primary_path(proj) + _apply_workspace(task_id, primary, proj.name) + + return json.dumps({"success": True, "id": proj.id, "slug": proj.slug, "name": proj.name, "primary_path": primary}) + + +registry.register( + name="project_list", + toolset="project", + schema={ + "name": "project_list", + "description": "List the desktop Projects (named workspaces) and which one is active.", + "parameters": {"type": "object", "properties": {}}, + }, + handler=lambda args, **kw: project_list(task_id=kw.get("task_id")), +) + +registry.register( + name="project_create", + toolset="project", + schema={ + "name": "project_create", + "description": ( + "Create a desktop Project (a named workspace) and switch this chat into it. " + "Pass `path` to anchor it to a repo/folder — this chat's workspace moves there " + "and the sidebar follows. Use when starting work in a new repo/folder; this is " + "the intentional way to move the session, not `cd`." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Human name, e.g. 'Aurora Demo'"}, + "path": {"type": "string", "description": "Primary repo/folder to anchor the project to"}, + }, + "required": ["name"], + }, + }, + handler=lambda args, **kw: project_create( + name=args.get("name", ""), path=args.get("path"), task_id=kw.get("task_id") + ), +) + +registry.register( + name="project_switch", + toolset="project", + schema={ + "name": "project_switch", + "description": ( + "Switch this chat into an existing desktop Project (by name, slug, or id). " + "Moves the session's workspace to the project's primary folder and the sidebar " + "follows. The intentional way to move between projects, not `cd`." + ), + "parameters": { + "type": "object", + "properties": { + "project": {"type": "string", "description": "Project name, slug, or id"}, + }, + "required": ["project"], + }, + }, + handler=lambda args, **kw: project_switch(project=args.get("project", ""), task_id=kw.get("task_id")), +) diff --git a/toolsets.py b/toolsets.py index 9efb32d8cee..ef7c41e9166 100644 --- a/toolsets.py +++ b/toolsets.py @@ -51,6 +51,11 @@ _HERMES_CORE_TOOLS = [ "text_to_speech", # Planning & memory "todo", "memory", + # NOTE: the desktop Project tools (project_list/create/switch) are + # deliberately NOT here. They only make sense where a GUI can follow the + # move, so they live in the `project` toolset and are enabled solely by the + # GUI gateway (tui_gateway/server.py::_load_enabled_toolsets) — keeping them + # off every CLI/messaging/cron schema (narrow waist). # Session history search "session_search", # Clarifying questions @@ -216,6 +221,12 @@ TOOLSETS = { "tools": ["session_search"], "includes": [] }, + + "project": { + "description": "Desktop Projects — create/switch named workspaces (GUI sessions only)", + "tools": ["project_list", "project_create", "project_switch"], + "includes": [] + }, "clarify": { "description": "Ask the user clarifying questions (multiple-choice or open-ended)", From cb3f8ec03d7e5fb9664ad81b36276c834da476de Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH 06/19] fix(tools): isolate per-session worktree cwd --- tests/tools/test_file_tools_cwd_resolution.py | 80 +++++++++++++++++++ tests/tools/test_terminal_task_cwd.py | 5 +- tools/file_tools.py | 34 +++++++- tools/terminal_tool.py | 17 +++- 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/tests/tools/test_file_tools_cwd_resolution.py b/tests/tools/test_file_tools_cwd_resolution.py index 2e8356325ed..24e23477962 100644 --- a/tests/tools/test_file_tools_cwd_resolution.py +++ b/tests/tools/test_file_tools_cwd_resolution.py @@ -314,3 +314,83 @@ def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch): assert "WORKSPACE_PATCHED" in (workspace / "target.py").read_text() # And the decoy copy is untouched. assert (decoy / "target.py").read_text() == "DECOY_ORIGINAL\n" + + +# ── Fix D: shared terminal env must not leak its cwd across worktree sessions ─ +# (June 2026: two desktop sessions, each on its own worktree, share the single +# "default" terminal environment. Its `cwd` tracks whichever session ran the +# last command, so a file edit from the OTHER session resolved against that +# foreign cwd and silently landed in the wrong worktree. terminal_tool now +# stamps env.cwd_owner with the driving session; file tools trust the shared +# env's live cwd only when the resolving session owns it.) + + +class _FakeOwnedEnv: + def __init__(self, cwd: str, cwd_owner: str): + self.cwd = cwd + self.cwd_owner = cwd_owner + + +@pytest.fixture +def _two_worktree_sessions(tmp_path, monkeypatch): + """Two worktree sessions sharing one terminal env owned by session B.""" + wt_a = tmp_path / "wt_a" + wt_b = tmp_path / "wt_b" + main = tmp_path / "main" + for d in (wt_a, wt_b, main): + d.mkdir() + (d / "target.py").write_text(f"{d.name}\n") + monkeypatch.chdir(main) + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) + monkeypatch.setattr(ft, "_file_ops_cache", {}) + # Both sessions register their worktree cwd (TUI/desktop registration path). + terminal_tool.register_task_env_overrides("sess-a", {"cwd": str(wt_a)}) + terminal_tool.register_task_env_overrides("sess-b", {"cwd": str(wt_b)}) + # The shared "default" env: session B ran the last command, so its live cwd + # is wt_b and B owns it. + monkeypatch.setattr( + terminal_tool, + "_active_environments", + {"default": _FakeOwnedEnv(str(wt_b), "sess-b")}, + ) + return wt_a, wt_b, main + + +def test_live_cwd_ignored_for_non_owning_session(_two_worktree_sessions): + wt_a, wt_b, _main = _two_worktree_sessions + # Owner sees the live cwd; the other session must NOT inherit it. + assert ft._get_live_tracking_cwd("sess-b") == str(wt_b) + assert ft._get_live_tracking_cwd("sess-a") is None + + +def test_resolution_routes_to_resolving_sessions_worktree(_two_worktree_sessions): + """The wrong-worktree fix: A resolves into wt_a, not the shared env's wt_b.""" + wt_a, wt_b, _main = _two_worktree_sessions + # Session A does not own the shared env → falls back to its own registered + # worktree cwd instead of B's live cwd. + resolved_a = ft._resolve_path_for_task("target.py", task_id="sess-a") + assert resolved_a == (wt_a / "target.py") + assert not str(resolved_a).startswith(str(wt_b)) + + +def test_owning_session_still_resolves_against_live_cwd(_two_worktree_sessions): + """No regression: the owner keeps resolving against the live cwd.""" + wt_a, wt_b, _main = _two_worktree_sessions + resolved_b = ft._resolve_path_for_task("target.py", task_id="sess-b") + assert resolved_b == (wt_b / "target.py") + assert not str(resolved_b).startswith(str(wt_a)) + + +def test_unknown_owner_keeps_prior_single_session_behavior(tmp_path, monkeypatch): + """An env with no owner (CLI / legacy) still yields its live cwd.""" + ws = tmp_path / "ws" + ws.mkdir() + monkeypatch.setattr(ft, "_file_ops_cache", {}) + monkeypatch.setattr( + terminal_tool, + "_active_environments", + {"default": _FakeOwnedEnv(str(ws), "")}, + ) + assert ft._get_live_tracking_cwd("default") == str(ws) + assert ft._get_live_tracking_cwd("any-session") == str(ws) diff --git a/tests/tools/test_terminal_task_cwd.py b/tests/tools/test_terminal_task_cwd.py index b49e8e1e6fa..9d0388d8c94 100644 --- a/tests/tools/test_terminal_task_cwd.py +++ b/tests/tools/test_terminal_task_cwd.py @@ -149,11 +149,14 @@ def test_background_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch) ) assert result["exit_code"] == 0 + # session_key falls back to the raw task_id when no gateway contextvar is set + # (it doesn't propagate to tool-worker threads), so process.kill / stop can + # still find and terminate this background process. assert registry.calls == [{ "command": "sleep 1", "cwd": "/workspace/live", "task_id": task_id, - "session_key": "", + "session_key": task_id, "env_vars": {}, "use_pty": False, }] diff --git a/tools/file_tools.py b/tools/file_tools.py index 3a9c10a520d..59c7214593d 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -166,6 +166,30 @@ def _registered_task_cwd_override(task_id: str = "default") -> str | None: return _sentinel_free_abs_cwd(overrides.get("cwd")) +def _live_cwd_if_owned(env, task_id: str) -> str | None: + """The env's live cwd, but only when THIS session owns it. + + The terminal env is shared (collapsed to the ``"default"`` container), so its + ``cwd`` tracks the LAST session that ran a command. With two worktree + sessions open, trusting it blindly routes one session's edits into the other + session's checkout (the wrong-worktree-patch bug). ``terminal_tool`` stamps + ``env.cwd_owner`` with the session that last drove the env; return its cwd + only when that owner matches the resolving session, else ``None`` so the + caller falls through to this session's own registered cwd override. Unknown + owner / ``default`` keys keep the prior behavior (single-session / CLI). + """ + if env is None: + return None + live = getattr(env, "cwd", None) + if not live: + return None + owner = str(getattr(env, "cwd_owner", "") or "") + tid = str(task_id or "") + if owner and tid and owner != "default" and tid != "default" and owner != tid: + return None + return live + + def _get_live_tracking_cwd(task_id: str = "default") -> str | None: """Return the task's live terminal cwd for bookkeeping when available.""" try: @@ -177,18 +201,20 @@ def _get_live_tracking_cwd(task_id: str = "default") -> str | None: with _file_ops_lock: cached = _file_ops_cache.get(container_key) or _file_ops_cache.get(task_id) if cached is not None: - live_cwd = getattr(getattr(cached, "env", None), "cwd", None) or getattr( - cached, "cwd", None - ) + env = getattr(cached, "env", None) + live_cwd = _live_cwd_if_owned(env, task_id) if live_cwd: return live_cwd + # Legacy: a cache entry carrying its own cwd with no env to own it. + if env is None and getattr(cached, "cwd", None): + return getattr(cached, "cwd", None) try: from tools.terminal_tool import _active_environments, _env_lock with _env_lock: env = _active_environments.get(container_key) or _active_environments.get(task_id) - live_cwd = getattr(env, "cwd", None) if env is not None else None + live_cwd = _live_cwd_if_owned(env, task_id) if live_cwd: return live_cwd except Exception: diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 11b6ad7a86c..6a5a6af1fdf 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2290,14 +2290,27 @@ def terminal_tool( "EOF." ) + # Claim the (shared "default") terminal env for the session driving this + # command. File tools read env.cwd_owner to decide whether the env's live + # cwd is THIS session's `cd` or a different worktree session's — without + # it, two open worktree sessions sharing the env route each other's edits + # to the wrong checkout. get_current_session_key()'s contextvar doesn't + # cross tool-worker threads, so fall back to the raw task_id (which IS the + # session_key for the top-level agent) — a stable, thread-safe anchor. + from tools.approval import get_current_session_key + + session_key = get_current_session_key(default="") or (task_id or "") + try: + env.cwd_owner = session_key + except Exception: + pass + if background: # Spawn a tracked background process via the process registry. # For local backends: uses subprocess.Popen with output buffering. # For non-local backends: runs inside the sandbox via env.execute(). - from tools.approval import get_current_session_key from tools.process_registry import process_registry - session_key = get_current_session_key(default="") effective_cwd = _resolve_command_cwd( workdir=workdir, env=env, From 86e748df13af643f54bc6c15044c358ce92e52c8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH 07/19] fix(agent): require code for coding posture --- agent/coding_context.py | 61 +++++++++++++++++++++++++++++- tests/agent/test_coding_context.py | 24 +++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/agent/coding_context.py b/agent/coding_context.py index 944083fe1b6..78229bc4f55 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -83,6 +83,59 @@ _PROJECT_MARKERS = ( # Agent-instruction files surfaced separately from manifests in the snapshot. _CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules") +# Source-file extensions that make a git repo a *code* workspace even with no +# manifest. Without this, `git init` on a notes/writing/research folder (a huge +# non-coding use case) would flip the whole session into the coding posture just +# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`). +_CODE_EXTENSIONS = frozenset({ + ".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", + ".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h", + ".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs", + ".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl", + ".hs", ".clj", ".erl", ".pl", +}) + +# Dirs never worth scanning for the code check (deps/build/vcs/venv noise). +_CODE_SCAN_SKIP_DIRS = frozenset({ + ".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build", + "target", ".next", ".turbo", "vendor", +}) + +# Bounded sweep: a code workspace reveals itself in the first handful of entries. +_CODE_SCAN_MAX_ENTRIES = 500 + + +def _has_code_files(root: Path) -> bool: + """Cheap, bounded check for source files in a repo's top two levels. + + Lets a git repo of loose scripts (no manifest) still read as a code + workspace while a bare notes/writing repo does not. Scans the root and its + immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats — + a handful of readdirs at session start, not a full walk. + """ + seen = 0 + stack = [(root, True)] + while stack: + directory, is_root = stack.pop() + try: + with os.scandir(directory) as entries: + for entry in entries: + seen += 1 + if seen > _CODE_SCAN_MAX_ENTRIES: + return False + name = entry.name + try: + if entry.is_file(): + if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS: + return True + elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."): + stack.append((Path(entry.path), False)) + except OSError: + continue + except OSError: + continue + return False + # Lockfile → package manager, checked in priority order. _PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv")) _JS_LOCKFILES = ( @@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str: if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS: return GENERAL_PROFILE.name cwd = Path(cwd_str) + # A recognized project root (manifest / AGENTS.md / .cursorrules) is a code + # workspace on its own — cheap stat checks, no scan. + if _marker_root(cwd) is not None: + return CODING_PROFILE.name git_root = _git_root(cwd) if git_root is not None and git_root == _home(): git_root = None # dotfiles repo at $HOME — not a code workspace - if git_root is not None or _marker_root(cwd) is not None: + # A bare git repo only counts when it actually holds code, so `git init` on a + # notes/writing/research folder stays in the general posture. + if git_root is not None and _has_code_files(git_root): return CODING_PROFILE.name return GENERAL_PROFILE.name diff --git a/tests/agent/test_coding_context.py b/tests/agent/test_coding_context.py index 80e58714559..b6606dfb147 100644 --- a/tests/agent/test_coding_context.py +++ b/tests/agent/test_coding_context.py @@ -23,9 +23,14 @@ def _git_init(path): "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "HOME": str(path), } + # Commit a source file so the fixture is a real *code* workspace: a bare git + # repo with no code no longer flips into the coding posture (see + # _detect_profile_name / _has_code_files), so "a code repo" needs code. + (Path(path) / "main.py").write_text("print('hi')\n") for args in ( ["init", "-q", "-b", "main"], - ["commit", "-q", "--allow-empty", "-m", "init commit"], + ["add", "-A"], + ["commit", "-q", "-m", "init commit"], ): subprocess.run([shutil.which("git"), "-C", str(path), *args], check=True, env=env) @@ -48,6 +53,23 @@ class TestIsCodingContext: _git_init(tmp_path) assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True + def test_auto_bare_git_repo_without_code_stays_general(self, tmp_path): + # A git repo of only prose (notes/writing/research — a big non-coding use + # case) is NOT a code workspace: .git alone must not flip the posture. + cfg = {"agent": {"coding_context": "auto"}} + env = { + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "HOME": str(tmp_path), + } + (tmp_path / "notes.md").write_text("# my novel\n") + for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "notes"]): + subprocess.run([shutil.which("git"), "-C", str(tmp_path), *args], check=True, env=env) + + assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False + # …but adding a manifest or source file makes it a code workspace. + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") + assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True + def test_auto_skips_messaging_surfaces(self, tmp_path): _git_init(tmp_path) cfg = {"agent": {"coding_context": "auto"}} From e2b801872992867c3e48630f22f939fcf0d807c9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH 08/19] feat(desktop): add git worktree and review IPC --- apps/desktop/electron/git-repo-scan.cjs | 98 +++ apps/desktop/electron/git-review-ops.cjs | 679 ++++++++++++++++++ apps/desktop/electron/git-review-ops.test.cjs | 22 + apps/desktop/electron/git-worktree-ops.cjs | 291 ++++++++ .../electron/git-worktree-ops.test.cjs | 172 +++++ apps/desktop/electron/git-worktrees.cjs | 174 ----- apps/desktop/electron/main.cjs | 202 +++++- apps/desktop/electron/preload.cjs | 30 +- apps/desktop/scripts/bundle-electron-main.mjs | 33 + apps/desktop/src/global.d.ts | 156 +++- 10 files changed, 1666 insertions(+), 191 deletions(-) create mode 100644 apps/desktop/electron/git-repo-scan.cjs create mode 100644 apps/desktop/electron/git-review-ops.cjs create mode 100644 apps/desktop/electron/git-review-ops.test.cjs create mode 100644 apps/desktop/electron/git-worktree-ops.cjs create mode 100644 apps/desktop/electron/git-worktree-ops.test.cjs delete mode 100644 apps/desktop/electron/git-worktrees.cjs create mode 100644 apps/desktop/scripts/bundle-electron-main.mjs diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.cjs new file mode 100644 index 00000000000..7b56eed40c2 --- /dev/null +++ b/apps/desktop/electron/git-repo-scan.cjs @@ -0,0 +1,98 @@ +'use strict' + +// Repo-first discovery: walk bounded roots for git repos using only Node's `fs` +// — no native addon, so it just works for anyone who pulls main (no +// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` +// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the +// first scan stays fast. Results are cached by the backend after the first run. + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const fsp = fs.promises + +// Shallow on purpose: real projects live a few levels under home +// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always +// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos +// you actually use but keep deeper still surface via session-derived discovery, +// so this only prunes noise, never repos with history. +const DEFAULT_MAX_DEPTH = 3 +const MAX_CONCURRENCY = 32 + +// Big trees that are never themselves repos and would waste the walk. Anything +// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this +// only needs the non-hidden heavyweights. +const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv']) + +async function mapLimit(items, limit, fn) { + let cursor = 0 + + async function worker() { + while (cursor < items.length) { + const index = cursor + cursor += 1 + await fn(items[index]) + } + } + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) +} + +/** + * Scan `roots` (default: the home dir) for git repositories. Returns deduped + * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). + */ +async function scanGitRepos(roots, options = {}) { + const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH + const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] + const found = new Map() + + async function walk(dir, depth) { + if (depth > maxDepth) { + return + } + + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return // unreadable / permission denied + } + + // A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git` + // FILE is a linked worktree or submodule — those belong to their parent + // repo as lanes, not as separate projects, so we don't list them (and we + // keep descending in case a real repo sits deeper). This is what kills the + // worktree/eval-repo duplicate explosion. + if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) { + const root = dir.replace(/[/\\]+$/, '') + found.set(root, path.basename(root) || root) + + return + } + + const subdirs = [] + for (const entry of entries) { + // Real directories only (skip symlinks to avoid loops), no hidden dirs, no + // known heavy trees. + if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) { + continue + } + + subdirs.push(path.join(dir, entry.name)) + } + + await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1)) + } + + await mapLimit( + searchRoots.map(root => String(root || '').trim()).filter(Boolean), + MAX_CONCURRENCY, + root => walk(root, 0) + ) + + return [...found.entries()].map(([root, label]) => ({ label, root })) +} + +module.exports = { scanGitRepos } diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.cjs new file mode 100644 index 00000000000..19b4aecf92d --- /dev/null +++ b/apps/desktop/electron/git-review-ops.cjs @@ -0,0 +1,679 @@ +'use strict' + +// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` +// (a maintained wrapper around the system git binary — same git the rest of the +// app shells to, no native build) so we read structured status()/diffSummary() +// results instead of hand-parsing porcelain. Reads degrade to null/empty on a +// non-repo / remote backend; mutations reject so the renderer can toast. + +const { execFile } = require('node:child_process') +const fs = require('node:fs/promises') +const path = require('node:path') + +const simpleGit = require('simple-git') + +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 +const COMMIT_CONTEXT_UNTRACKED_MAX = 80 +const UNTRACKED_LINE_COUNT_CONCURRENCY = 16 +const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024 + +// GUI-launched Electron apps on macOS inherit only a minimal PATH (no +// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out +// to — aren't found. Augment PATH with the resolved gh dir + the common +// package-manager bins so gh runs the same way it does in a terminal. +function ghEnv(ghBin) { + const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter( + dir => dir && dir !== '.' + ) + + return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) } +} + +// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on +// availability/auth without a throw. gh missing/unauthed → ok:false. +function runGh(args, cwd, ghBin) { + return new Promise(resolve => { + execFile( + ghBin || 'gh', + args, + { cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') }) + ) + }) +} + +function gitFor(cwd, gitBin) { + return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false }) +} + +// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve +// to the NEW path so the row addresses the real file for diff/stage. +function resolveRenamePath(raw) { + const path = String(raw || '').trim() + + if (!path.includes(' => ')) { + return path + } + + const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/) + + if (brace) { + const [, prefix, , to, suffix] = brace + + return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/') + } + + return path.split(' => ').pop().trim() +} + +// DiffResult.files → Map (binary files carry no line +// delta). +function countsByPath(summary) { + const map = new Map() + + for (const file of summary.files) { + map.set(resolveRenamePath(file.file), { + added: file.binary ? 0 : file.insertions, + removed: file.binary ? 0 : file.deletions + }) + } + + return map +} + +// Untracked files don't appear in diffSummary(); count insertions from disk so +// the review tree can show +N for new files (matches an all-add diff view). +// Insertions = line count: newline bytes, plus one for a final unterminated +// line. Binary (NUL byte) → 0, mirroring git numstat's "-". +async function untrackedInsertions(cwd, relPath) { + try { + const fullPath = path.join(cwd, relPath) + const stat = await fs.stat(fullPath) + + if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) { + return 0 + } + + const buf = await fs.readFile(fullPath) + + if (buf.includes(0)) { + return 0 + } + + let lines = 0 + + for (const byte of buf) { + if (byte === 10) { + lines++ + } + } + + return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines + } catch { + return 0 + } +} + +function capText(text, maxChars, label = 'truncated') { + const value = String(text || '') + + if (value.length <= maxChars) { + return value + } + + return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n` +} + +async function fillUntrackedCounts(cwd, files) { + const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0) + + for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { + await Promise.all( + pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => { + file.added = await untrackedInsertions(cwd, file.path) + }) + ) + } +} + +// Resolve the base ref for "all branch changes": merge-base with the remote +// default branch (origin/HEAD), falling back to common trunk names. +async function branchBase(git) { + const candidates = [] + + try { + const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim() + + if (head) { + candidates.push(head) + } + } catch { + // No origin/HEAD configured. + } + + candidates.push('origin/main', 'origin/master', 'main', 'master') + + for (const ref of candidates) { + try { + const base = (await git.raw(['merge-base', 'HEAD', ref])).trim() + + if (base) { + return base + } + } catch { + // Ref doesn't exist; try the next candidate. + } + } + + return null +} + +// Resolve the repo's default branch NAME ("main" / "master" / …), preferring +// the remote's HEAD, then common local trunk names. Null when none is found +// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the +// trunk" regardless of which branch you're currently on. +async function defaultBranchName(git) { + try { + const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim() + + // "origin/main" → "main"; skip the bare "origin/HEAD" placeholder. + if (head && head !== 'origin/HEAD') { + return head.replace(/^origin\//, '') + } + } catch { + // No origin/HEAD configured. + } + + // Prefer a local trunk, then a remote-only one (returns the clean name either + // way) so "branch off main" works even before main is checked out locally. + for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) { + try { + await git.raw(['rev-parse', '--verify', '--quiet', ref]) + + return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '') + } catch { + // Ref doesn't exist; try the next candidate. + } + } + + return null +} + +// A status file's single-letter classification, preferring the staged (index) +// code over the worktree code; untracked wins (simple-git marks both '?'). +function statusLetter(file) { + if (file.index === '?' || file.working_dir === '?') { + return '?' + } + + const code = file.index && file.index !== ' ' ? file.index : file.working_dir + + return (code || 'M').toUpperCase() +} + +const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?') + +async function reviewList(repoPath, scope, baseRef, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' }) + } catch { + return { files: [], base: null } + } + + const git = gitFor(cwd, gitBin) + + try { + if (scope === 'branch' || scope === 'lastTurn') { + const base = scope === 'branch' ? await branchBase(git) : baseRef + + if (!base) { + return { files: [], base: null } + } + + const range = scope === 'branch' ? `${base}...HEAD` : base + const summary = await git.diffSummary([range]) + const files = summary.files.map(file => ({ + path: resolveRenamePath(file.file), + added: file.binary ? 0 : file.insertions, + removed: file.binary ? 0 : file.deletions, + status: 'M', + staged: false + })) + + // "Last turn" also surfaces files created since the baseline (untracked). + if (scope === 'lastTurn') { + const status = await git.status() + + for (const path of status.not_added) { + if (!files.some(f => f.path === path)) { + files.push({ path, added: 0, removed: 0, status: '?', staged: false }) + } + } + } + + files.sort((a, b) => a.path.localeCompare(b.path)) + await fillUntrackedCounts(cwd, files) + + return { files, base } + } + + // Default: uncommitted (staged + unstaged + untracked), one row per path. + const [status, staged, unstaged] = await Promise.all([ + git.status(), + git.diffSummary(['--cached']), + git.diffSummary([]) + ]) + const stagedCounts = countsByPath(staged) + const unstagedCounts = countsByPath(unstaged) + + const files = status.files.map(file => { + const filePath = resolveRenamePath(file.path) + const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 } + const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 } + + return { + path: filePath, + added: sc.added + uc.added, + removed: sc.removed + uc.removed, + status: statusLetter(file), + staged: isStaged(file) + } + }) + + files.sort((a, b) => a.path.localeCompare(b.path)) + await fillUntrackedCounts(cwd, files) + + return { files, base: null } + } catch { + return { files: [], base: null } + } +} + +async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' }) + } catch { + return '' + } + + const git = gitFor(cwd, gitBin) + const safe = args => git.diff(args).catch(() => '') + + if (scope === 'branch') { + const base = await branchBase(git) + + return base ? safe([`${base}...HEAD`, '--', filePath]) : '' + } + + if (scope === 'lastTurn') { + return baseRef ? safe([baseRef, '--', filePath]) : '' + } + + if (staged) { + return safe(['--cached', '--', filePath]) + } + + const worktree = await safe(['--', filePath]) + + if (worktree.trim()) { + return worktree + } + + // Untracked file: no worktree diff exists, so synthesize an all-add diff via + // --no-index (exits non-zero by design when files differ, so go around + // simple-git's reject-on-nonzero with a raw execFile). + return new Promise(resolve => { + execFile( + gitBin || 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, + (_err, stdout) => resolve(String(stdout || '')) + ) + }) +} + +// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last +// commit" view used by the file preview. Unlike reviewDiff this never synthesizes +// a full-add for a clean tracked file (so a pristine file shows no diff); it only +// all-adds a genuinely untracked file. +async function fileDiffVsHead(repoPath, filePath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' }) + } catch { + return '' + } + + const git = gitFor(cwd, gitBin) + const head = await git.diff(['HEAD', '--', filePath]).catch(() => '') + + if (head.trim()) { + return head + } + + // No tracked changes vs HEAD. Only synthesize an all-add diff for a file git + // doesn't know yet; a clean tracked file must return empty. + const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '') + + if (!status.trim().startsWith('??')) { + return '' + } + + return new Promise(resolve => { + execFile( + gitBin || 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, + (_err, stdout) => resolve(String(stdout || '')) + ) + }) +} + +async function reviewStage(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' }) + + await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A']) + + return { ok: true } +} + +async function reviewUnstage(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' }) + + await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD']) + + return { ok: true } +} + +// Discard changes back to the committed state. Destructive — the renderer +// confirms first. Restores tracked files and removes untracked ones. +async function reviewRevert(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' }) + const git = gitFor(cwd, gitBin) + + if (filePath) { + await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined) + await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined) + } else { + await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined) + await git.raw(['clean', '-fd']).catch(() => undefined) + } + + return { ok: true } +} + +// Resolve a ref to a commit sha (captures the turn baseline for "Last turn"). +async function reviewRevParse(repoPath, ref, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' }) + } catch { + return null + } + + try { + return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null + } catch { + return null + } +} + +// Commit the working tree. Mirrors VS Code: if nothing is staged, stage +// everything first ("commit all"), then commit. Optionally push afterward, +// setting upstream on the first push. +async function reviewCommit(repoPath, message, push, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' }) + const git = gitFor(cwd, gitBin) + const status = await git.status() + + if (status.staged.length === 0) { + await git.raw(['add', '-A']) + } + + await git.commit(message) + + if (push) { + const fresh = await git.status() + + if (fresh.tracking) { + await git.push() + } else if (fresh.current) { + await git.raw(['push', '-u', 'origin', fresh.current]) + } + } + + return { ok: true } +} + +// Gather the context the model needs to draft a commit message: the diff of +// what *will* be committed (staged when anything is staged, else everything +// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule), +// the names of untracked files (which carry no diff), and recent commit +// subjects for style. Diff is capped so the payload stays bounded. Reads only. +async function reviewCommitContext(repoPath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' }) + } catch { + return { diff: '', recent: '' } + } + + const git = gitFor(cwd, gitBin) + const safe = args => git.diff(args).catch(() => '') + + let status + try { + status = await git.status() + } catch { + return { diff: '', recent: '' } + } + + // What will land: staged changes if any, otherwise all tracked changes vs HEAD. + let diff = capText( + status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']), + COMMIT_CONTEXT_DIFF_MAX_CHARS, + 'diff truncated for commit-message generation' + ) + + // Untracked files have no diff — list them so new files aren't invisible. + const untracked = status.not_added || [] + if (untracked.length > 0) { + const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) + const omitted = untracked.length - visible.length + const note = + `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + + (omitted > 0 ? `# ... ${omitted} more omitted\n` : '') + + diff = diff ? `${diff}${note}` : note + } + + const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '') + + return { diff: diff || '', recent: String(recent || '').trim() } +} + +async function reviewPush(repoPath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' }) + const git = gitFor(cwd, gitBin) + const status = await git.status() + + if (status.tracking) { + await git.push() + } else if (status.current) { + await git.raw(['push', '-u', 'origin', status.current]) + } + + return { ok: true } +} + +// gh availability + auth + whether this branch already has a PR. Reads only; +// drives the PR button's enabled/label state. `ghReady` is false when gh is +// missing OR not authenticated — either way the PR action can't run. +async function reviewShipInfo(repoPath, ghBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' }) + } catch { + return { ghReady: false, pr: null } + } + + const auth = await runGh(['auth', 'status'], cwd, ghBin) + + if (!auth.ok) { + return { ghReady: false, pr: null } + } + + const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin) + + if (!view.ok) { + // gh exits non-zero when no PR exists for the branch — that's not an error. + return { ghReady: true, pr: null } + } + + try { + const pr = JSON.parse(view.stdout) + + return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null } + } catch { + return { ghReady: true, pr: null } + } +} + +// Create a PR for the current branch (pushing first so gh has a remote ref), +// letting gh fill title/body from the commits. Returns the new PR url. +async function reviewCreatePr(repoPath, gitBin, ghBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' }) + + await reviewPush(repoPath, gitBin).catch(() => undefined) + + const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin) + + if (!created.ok) { + throw new Error('gh pr create failed (is gh installed and authenticated?)') + } + + const url = created.stdout.trim().split('\n').filter(Boolean).pop() || '' + + return { url } +} + +// Compact working-tree status for the composer coding rail: branch, ahead/behind, +// per-state change counts, +/- vs HEAD, and a capped changed-file list. +async function repoStatus(repoPath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' }) + } catch { + return null + } + + // Session cwds can point at a deleted worktree for a moment (or forever in a + // stale row). simple-git throws at construction time on a missing baseDir, so + // fail soft and hide the coding rail instead of spamming IPC handler errors. + try { + const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { + return null + } + } catch { + return null + } + + let git + try { + git = gitFor(cwd, gitBin) + } catch { + return null + } + let status + + try { + status = await git.status() + } catch { + // Not a repo / git unavailable / remote backend. + return null + } + + const detached = typeof status.detached === 'boolean' ? status.detached : !status.current + const files = status.files.map(file => ({ + path: file.path, + staged: isStaged(file), + unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'), + untracked: file.index === '?' || file.working_dir === '?', + conflicted: file.index === 'U' || file.working_dir === 'U' + })) + + const result = { + branch: detached ? null : status.current || null, + defaultBranch: await defaultBranchName(git), + detached, + ahead: status.ahead || 0, + behind: status.behind || 0, + staged: files.filter(f => f.staged).length, + unstaged: files.filter(f => f.unstaged).length, + untracked: status.not_added.length, + conflicted: status.conflicted.length, + changed: files.length, + added: 0, + removed: 0, + files: files.slice(0, 200) + } + + // +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0. + try { + const summary = await git.diffSummary(['HEAD']) + result.added = summary.insertions + result.removed = summary.deletions + } catch { + // No commits yet. + } + + // `git diff HEAD` ignores untracked files, so a turn that only creates new + // files (the common case — a fresh module, a demo dir) showed +0 in the rail + // while the review pane counted them. Fold untracked insertions into `added` + // so the rail matches reality. Bounded (size cap + concurrency) like the + // review tree; only the capped file slice is counted so a huge untracked tree + // can't stall the probe. + try { + const untracked = status.not_added.slice(0, 500) + for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { + const batch = await Promise.all( + untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) + ) + result.added += batch.reduce((sum, n) => sum + n, 0) + } + } catch { + // Best-effort: a probe failure just leaves untracked lines uncounted. + } + + return result +} + +module.exports = { + branchBase, + fileDiffVsHead, + repoStatus, + resolveRenamePath, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewList, + reviewPush, + reviewRevParse, + reviewRevert, + reviewShipInfo, + reviewStage, + reviewUnstage +} diff --git a/apps/desktop/electron/git-review-ops.test.cjs b/apps/desktop/electron/git-review-ops.test.cjs new file mode 100644 index 00000000000..fdddd13df78 --- /dev/null +++ b/apps/desktop/electron/git-review-ops.test.cjs @@ -0,0 +1,22 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') + +const { resolveRenamePath } = require('./git-review-ops.cjs') + +test('resolveRenamePath: plain path is unchanged', () => { + assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') +}) + +test('resolveRenamePath: simple rename resolves to the new path', () => { + assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts') +}) + +test('resolveRenamePath: brace rename resolves to the new path', () => { + assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts') +}) + +test('resolveRenamePath: brace rename collapsing a segment', () => { + assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts') +}) diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.cjs new file mode 100644 index 00000000000..98373d90563 --- /dev/null +++ b/apps/desktop/electron/git-worktree-ops.cjs @@ -0,0 +1,291 @@ +'use strict' + +// Git-driven worktree operations for the desktop "Start work" flow: spin up a +// fresh worktree the lightest way (`git worktree add -b`), list real worktrees, +// and remove them. Git is the source of truth; the renderer just drives these. + +const path = require('node:path') +const fs = require('node:fs') +const { execFile } = require('node:child_process') + +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +function runGit(gitBin, args, cwd) { + return new Promise((resolve, reject) => { + execFile( + gitBin, + args, + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout, stderr) => { + if (err) { + err.stderr = String(stderr || '') + reject(err) + + return + } + + resolve(String(stdout || '')) + } + ) + }) +} + +// Parse `git worktree list --porcelain`. The first record is the main worktree. +function parseWorktrees(out) { + const trees = [] + let cur = null + + for (const line of out.split('\n')) { + if (line.startsWith('worktree ')) { + if (cur) { + trees.push(cur) + } + + cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false } + } else if (!cur) { + continue + } else if (line.startsWith('branch ')) { + cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '') + } else if (line === 'detached') { + cur.detached = true + } else if (line === 'bare') { + cur.bare = true + } else if (line.startsWith('locked')) { + cur.locked = true + } + } + + if (cur) { + trees.push(cur) + } + + return trees +} + +async function listWorktrees(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' }) + } catch { + return [] + } + + try { + const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved) + + return parseWorktrees(out).map((tree, index) => ({ + path: tree.path, + branch: tree.branch, + isMain: index === 0, + detached: tree.detached, + locked: tree.locked + })) + } catch { + return [] + } +} + +// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges), +// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad +// value can't reach `git` no matter the caller (the GUI also enforces live). +function sanitizeBranch(name) { + return String(name || '') + .replace(/\s+/g, '-') + .replace(/[^\w./-]/g, '') + .replace(/-{2,}/g, '-') + .replace(/\/{2,}/g, '/') + .replace(/\.{2,}/g, '.') + .replace(/^[-./]+|[-./]+$/g, '') +} + +function slugify(name) { + const slug = String(name || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/g, '') + + return slug || 'work' +} + +// A brand-new project folder isn't a git repo — and a freshly-init'd one has no +// commit to branch from — so `git worktree add` would fail. Make the dir a repo +// with a root commit on the user's behalf so worktrees "just work". No-op for a +// repo that already has commits; never touches the user's files (the seed commit +// is `--allow-empty`), and never inits a dir that already lives inside a repo. +async function ensureGitRepo(gitBin, dir) { + let needsRoot = false + + try { + const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim() + + if (inside !== 'true') { + await runGit(gitBin, ['init'], dir) + needsRoot = true + } else { + // Repo exists; a worktree still needs a HEAD to branch from. + try { + await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir) + } catch { + needsRoot = true + } + } + } catch { + await runGit(gitBin, ['init'], dir) + needsRoot = true + } + + if (needsRoot) { + // Inline identity so the seed commit lands even with no global git config. + await runGit( + gitBin, + ['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'], + dir + ) + } +} + +// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the +// primary checkout even when called from a linked worktree. +async function mainRoot(gitBin, cwd) { + const list = await listWorktrees(cwd, gitBin) + const main = list.find(tree => tree.isMain) + + return main ? main.path : cwd +} + +function uniqueDir(base) { + let dir = base + let n = 1 + + while (fs.existsSync(dir)) { + n += 1 + dir = `${base}-${n}` + } + + return dir +} + +async function addWorktree(repoPath, options, gitBin) { + const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' }) + // A new project's folder may not be a git repo yet — init it (with a root + // commit) so the worktree has something to branch from. + await ensureGitRepo(gitBin, resolved) + const root = await mainRoot(gitBin, resolved) + const opts = options || {} + + // "Convert an existing branch into a worktree": check the branch out into a + // fresh worktree dir as-is (no `-b`, no new branch). Dir is named off the + // branch slug so it reads like the branch it carries. + if (opts.existingBranch) { + const existing = sanitizeBranch(opts.existingBranch) + + if (!existing) { + throw new Error('Branch name is required.') + } + + const dir = uniqueDir(path.join(root, '.worktrees', slugify(existing))) + await runGit(gitBin, ['worktree', 'add', dir, existing], root) + + return { path: dir, branch: existing, repoRoot: root } + } + + const slug = slugify(opts.name || `work-${Date.now().toString(36)}`) + const branch = sanitizeBranch(opts.branch) || `hermes/${slug}` + const dir = uniqueDir(path.join(root, '.worktrees', slug)) + + const args = ['worktree', 'add', '-b', branch, dir] + + if (opts.base) { + args.push(String(opts.base)) + } + + try { + await runGit(gitBin, args, root) + } catch (err) { + // Branch name may already exist — retry checking out the existing branch + // into a fresh worktree dir instead of failing the whole flow. + if (/already exists/i.test(err.stderr || '')) { + await runGit(gitBin, ['worktree', 'add', dir, branch], root) + } else { + throw err + } + } + + return { path: dir, branch, repoRoot: root } +} + +async function removeWorktree(repoPath, worktreePath, options, gitBin) { + const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' }) + const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' }) + const root = await mainRoot(gitBin, resolvedRepo) + const args = ['worktree', 'remove'] + + if (options && options.force) { + args.push('--force') + } + + args.push(resolvedTree) + await runGit(gitBin, args, root) + + return { removed: resolvedTree } +} + +// List local branches for the "convert a branch into a worktree" picker, most +// recently committed first. Each carries whether it's already checked out in a +// worktree and, when checked out, that worktree's path. Empty on a non-repo / +// remote backend where the probe can't run. +async function listBranches(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' }) + } catch { + return [] + } + + try { + const out = await runGit( + gitBin, + ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], + resolved + ) + const trees = await listWorktrees(resolved, gitBin) + const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) + + return out + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(name => ({ name, checkedOut: pathByBranch.has(name), worktreePath: pathByBranch.get(name) || null })) + } catch { + return [] + } +} + +async function switchBranch(repoPath, branch, gitBin) { + const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' }) + const target = sanitizeBranch(branch) + + if (!target) { + throw new Error('Branch name is required.') + } + + await runGit(gitBin, ['switch', target], resolved) + + return { branch: target } +} + +module.exports = { + addWorktree, + ensureGitRepo, + listBranches, + listWorktrees, + parseWorktrees, + removeWorktree, + sanitizeBranch, + switchBranch +} diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.cjs new file mode 100644 index 00000000000..ec6a96c9621 --- /dev/null +++ b/apps/desktop/electron/git-worktree-ops.test.cjs @@ -0,0 +1,172 @@ +'use strict' + +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const { + addWorktree, + ensureGitRepo, + listBranches, + parseWorktrees, + sanitizeBranch, + switchBranch +} = require('./git-worktree-ops.cjs') + +test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { + assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') + assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing') + assert.equal(sanitizeBranch(' wip~^:? '), 'wip') + assert.equal(sanitizeBranch('///'), '') +}) + +test('parseWorktrees: main checkout + linked worktree', () => { + const out = [ + 'worktree /repo', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /repo/.worktrees/feat', + 'HEAD def456', + 'branch refs/heads/hermes/feat', + '' + ].join('\n') + + const trees = parseWorktrees(out) + + assert.equal(trees.length, 2) + assert.equal(trees[0].path, '/repo') + assert.equal(trees[0].branch, 'main') + assert.equal(trees[1].path, '/repo/.worktrees/feat') + assert.equal(trees[1].branch, 'hermes/feat') +}) + +test('parseWorktrees: detached + locked flags', () => { + const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n') + const trees = parseWorktrees(out) + + assert.equal(trees.length, 1) + assert.equal(trees[0].detached, true) + assert.equal(trees[0].locked, true) + assert.equal(trees[0].branch, null) +}) + +test('parseWorktrees: empty input', () => { + assert.deepEqual(parseWorktrees(''), []) +}) + +test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/) + + // The whole point: a worktree can now branch off the seeded root commit. + execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir }) + assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt'))) + + // Idempotent: an already-committed repo gets no extra commit. + await ensureGitRepo('git', dir) + assert.equal(git('rev-list', '--count', 'HEAD'), '1') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('switchBranch: switches a normal checkout branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + await switchBranch(dir, 'feature', 'git') + + assert.equal(git('branch', '--show-current'), 'feature') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: lists locals and flags the checked-out branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-')) + + try { + await ensureGitRepo('git', dir) + const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim() + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + const branches = await listBranches(dir, 'git') + const names = branches.map(b => b.name).sort() + + assert.deepEqual(names, [current, 'feature'].sort()) + // The repo's own checkout is flagged; the unused branch is convertible. + assert.equal(branches.find(b => b.name === current).checkedOut, true) + assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir)) + assert.equal(branches.find(b => b.name === 'feature').checkedOut, false) + assert.equal(branches.find(b => b.name === 'feature').worktreePath, null) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: a branch claimed by a worktree is flagged checked out', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-')) + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + // addWorktree converts the existing "feature" branch into a worktree. + const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git') + + assert.equal(result.branch, 'feature') + assert.ok(fs.existsSync(result.path)) + + const branches = await listBranches(dir, 'git') + + assert.equal(branches.find(b => b.name === 'feature').checkedOut, true) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: empty on a non-repo path', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-')) + + try { + assert.deepEqual(await listBranches(dir, 'git'), []) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: existingBranch checks the branch out without a new branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'cool/feature'], { cwd: dir }) + + const before = git('branch', '--list').split('\n').length + const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git') + + // No new branch was created — only the existing one is checked out. + assert.equal(git('branch', '--list').split('\n').length, before) + assert.equal(result.branch, 'cool/feature') + // Dir is named off the branch slug, nested under the main repo's .worktrees. + assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/) + assert.equal( + execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(), + 'cool/feature' + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-worktrees.cjs b/apps/desktop/electron/git-worktrees.cjs deleted file mode 100644 index 570397b2c95..00000000000 --- a/apps/desktop/electron/git-worktrees.cjs +++ /dev/null @@ -1,174 +0,0 @@ -'use strict' - -// Resolve git-worktree relationships for a set of session cwds, reading git's -// on-disk metadata directly (no `git` spawn per path): -// -// - A normal checkout has a `.git` DIRECTORY at its root → it's the main -// worktree; its repo root IS that directory's parent. -// - A linked worktree has a `.git` FILE: `gitdir: /.git/worktrees/`. -// That admin dir's `commondir` points back at the shared `/.git`, whose -// parent is the main repo root. -// -// Grouping by repoRoot therefore clusters a repo's main checkout with all of its -// linked worktrees, regardless of how the worktree directories are named. The -// branch (read from the worktree's own HEAD) gives each worktree a meaningful -// label. - -const fs = require('node:fs') -const path = require('node:path') -const { resolveRequestedPathForIpc } = require('./hardening.cjs') - -// Walk up from `start` to the nearest ancestor that carries a `.git` entry -// (file for a linked worktree, dir for the main checkout). Capped so a stray -// path can't loop forever. -function findGitHost(start, fsImpl) { - let dir = start - - for (let i = 0; i < 64; i += 1) { - const dotgit = path.join(dir, '.git') - - try { - if (fsImpl.existsSync(dotgit)) { - return dir - } - } catch { - return null - } - - const parent = path.dirname(dir) - - if (parent === dir) { - return null - } - - dir = parent - } - - return null -} - -function readBranch(gitDir, fsImpl) { - try { - const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim() - const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/) - - if (ref) { - return ref[1] - } - - // Detached HEAD: surface a short sha so the worktree still gets a label. - return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null - } catch { - return null - } -} - -// Given the directory that owns the `.git` entry, resolve its worktree identity. -function resolveFromHost(host, fsImpl) { - const dotgit = path.join(host, '.git') - let stat - - try { - stat = fsImpl.statSync(dotgit) - } catch { - return null - } - - if (stat.isDirectory()) { - return { - repoRoot: host, - worktreeRoot: host, - isMainWorktree: true, - branch: readBranch(dotgit, fsImpl) - } - } - - // Linked worktree: `.git` is a file pointing at the admin dir. - let contents - - try { - contents = fsImpl.readFileSync(dotgit, 'utf8').trim() - } catch { - return null - } - - const match = contents.match(/^gitdir:\s*(.+)$/m) - - if (!match) { - return null - } - - const adminDir = path.resolve(host, match[1].trim()) - - // `commondir` resolves to the shared `/.git`; fall back to walking two - // levels up from `/.git/worktrees/` if it's missing. - let commonDir - - try { - const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim() - commonDir = path.resolve(adminDir, rel) - } catch { - commonDir = path.dirname(path.dirname(adminDir)) - } - - return { - repoRoot: path.dirname(commonDir), - worktreeRoot: host, - isMainWorktree: false, - branch: readBranch(adminDir, fsImpl) - } -} - -function resolveWorktree(startPath, fsImpl = fs) { - let resolved - - try { - resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' }) - } catch { - return null - } - - let start = resolved - - try { - const stat = fsImpl.statSync(resolved) - - if (!stat.isDirectory()) { - start = path.dirname(resolved) - } - } catch { - return null - } - - const host = findGitHost(start, fsImpl) - - if (!host) { - return null - } - - return resolveFromHost(host, fsImpl) -} - -// Batch entry point for the renderer: maps each requested cwd to its worktree -// info (or null when it isn't inside a git checkout / can't be read). Dedupes so -// many sessions sharing a cwd cost one lookup. -async function worktreesForIpc(cwds, options = {}) { - const fsImpl = options.fs || fs - const list = Array.isArray(cwds) ? cwds : [] - const out = {} - - for (const cwd of list) { - if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) { - continue - } - - out[cwd] = resolveWorktree(cwd, fsImpl) - } - - return out -} - -module.exports = { - resolveWorktree, - worktreesForIpc -} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ce42e3474dc..5909e1d75c4 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -55,7 +55,23 @@ const { buildRelaunchScript } = require('./update-relaunch.cjs') const { gitRootForIpc } = require('./git-root.cjs') -const { worktreesForIpc } = require('./git-worktrees.cjs') +const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs') +const { + fileDiffVsHead, + repoStatus, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewList, + reviewPush, + reviewRevParse, + reviewRevert, + reviewShipInfo, + reviewStage, + reviewUnstage +} = require('./git-review-ops.cjs') +const { scanGitRepos } = require('./git-repo-scan.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') const { runRebuildWithRetry } = require('./update-rebuild.cjs') @@ -1503,6 +1519,30 @@ function resolveGitBinary() { return _gitBinaryCache } +// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH +// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually +// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's +// terminal. Check the common install locations first, then PATH. Cached. +let _ghBinaryCache = null +function resolveGhBinary() { + if (_ghBinaryCache) return _ghBinaryCache + + const candidates = [] + + if (IS_WINDOWS) { + candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) + if (process.env.LOCALAPPDATA) { + candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) + } + } else { + const home = app.getPath('home') + candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh')) + } + + _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' + return _ghBinaryCache +} + function recentHermesLog() { return hermesLog.slice(-20).join('\n') } @@ -2920,7 +2960,6 @@ async function ensureRuntime(backend) { return backend } - function fetchJson(url, token, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) @@ -6596,7 +6635,164 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) -ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds)) +// Reveal a path in the OS file manager (Finder / Explorer / Files). +ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + return false + } + + try { + shell.showItemInFolder(target) + + return true + } catch { + return false + } +}) + +// Rename a file/folder in place. The renderer passes the existing path + a new +// base name; the destination is resolved in the SAME parent dir so a rename can +// never move the item elsewhere or traverse out. Rejects on a name collision. +ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { + const src = String(targetPath || '').trim() + const name = String(newName || '').trim() + + if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error('Invalid rename') + } + + const dst = path.join(path.dirname(src), name) + + if (dst === src) { + return { path: dst } + } + + if (fs.existsSync(dst)) { + throw new Error(`"${name}" already exists`) + } + + await fs.promises.rename(src, dst) + + return { path: dst } +}) + +// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path +// is hardened (resolveRequestedPathForIpc) and the parent must already exist — +// this never creates directory trees or escapes the allowed roots, and content +// is size-capped so it can't be abused as a bulk-write primitive. +ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error('Invalid path') + } + + const text = String(content ?? '') + + if (text.length > 1_000_000) { + throw new Error('Content too large') + } + + const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) + + if (!directoryExists(path.dirname(resolved))) { + throw new Error('Parent directory does not exist') + } + + await fs.promises.writeFile(resolved, text, 'utf8') + + return { path: resolved } +}) + +// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" +// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. +ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + throw new Error('Invalid delete') + } + + await shell.trashItem(target) + + return true +}) + +// Git-driven worktree management ("Start work" flow). Errors surface to the +// renderer as rejected promises so it can toast a friendly message. +ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => + listWorktrees(repoPath, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => + addWorktree(repoPath, options || {}, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => + removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => + switchBranch(repoPath, branch, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => + listBranches(repoPath, resolveGitBinary()) +) + +// Compact repo status (branch, ahead/behind, change counts + files) for the +// composer coding rail. Returns null on a non-repo / remote backend so the rail +// hides cleanly rather than erroring. +ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) + +// Codex-style review pane: list changed files for a scope, fetch one file's +// unified diff, and stage / unstage / revert. Reads return empty on failure; +// mutations reject so the renderer can toast. +ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => + reviewList(repoPath, scope, baseRef, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => + reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) +) +// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). +ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => + fileDiffVsHead(repoPath, filePath, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => + reviewStage(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => + reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => + reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => + reviewRevParse(repoPath, ref, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => + reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => + reviewCommitContext(repoPath, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) +ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) +ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => + reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) +) + +// Repo-first project discovery: scan bounded roots for git repos (pure fs walk, +// no native addon). Never throws to the renderer — failures yield an empty list. +ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { + try { + return await scanGitRepos(roots || [], options || {}) + } catch { + return [] + } +}) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { if (!nodePty) { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 4edba83cf82..aa8bcc16128 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -82,7 +82,35 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), - worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds), + revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath), + renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName), + writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content), + trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath), + git: { + worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath), + worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options), + worktreeRemove: (repoPath, worktreePath, options) => + ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options), + branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch), + branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath), + repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath), + fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath), + scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options), + review: { + list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef), + diff: (repoPath, filePath, scope, baseRef, staged) => + ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged), + stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath), + unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath), + revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath), + revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref), + commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push), + commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath), + push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath), + shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath), + createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath) + } + }, terminal: { dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs new file mode 100644 index 00000000000..bb5b0ad061b --- /dev/null +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// bundle-electron-main.mjs — bundles electron/main.cjs into a single +// self-contained file so the nix build doesn't need to ship node_modules/. +// +// `electron` is provided by the runtime; `node-pty` is staged separately +// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main — +// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays +// as a separate file and doesn't need bundling. +import { build } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { renameSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const entry = resolve(root, 'electron/main.cjs') +const tmp = resolve(root, 'electron/main.bundled.cjs') + +await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: tmp, + external: ['electron', 'node-pty'], + logLevel: 'info' +}) + +// Overwrite the original with the bundled version. +renameSync(tmp, entry) + +console.log(`bundled ${entry}`) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 074ba05ef7e..1ad12008d52 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -93,10 +93,64 @@ declare global { getRecentLogs: () => Promise<{ path: string; lines: string[] }> readDir: (path: string) => Promise gitRoot?: (path: string) => Promise - // Resolve git-worktree identity for a batch of session cwds, reading git's - // on-disk metadata locally. Returns null per cwd that isn't inside a - // checkout (or can't be read — e.g. a remote backend's path). - worktrees?: (cwds: string[]) => Promise> + // Reveal a path in the OS file manager (Finder / Explorer). + revealPath?: (path: string) => Promise + // Rename a file/folder in place (new base name, same parent dir). + renamePath?: (path: string, newName: string) => Promise<{ path: string }> + // Write a small UTF-8 text file (hardened path, parent must exist). + writeTextFile?: (path: string, content: string) => Promise<{ path: string }> + // Move a file/folder to the OS trash (recoverable). + trashPath?: (path: string) => Promise + // Git-driven worktree management for the "Start work" flow. + git?: { + worktreeList: (repoPath: string) => Promise + worktreeAdd: ( + repoPath: string, + options?: { name?: string; branch?: string; base?: string; existingBranch?: string } + ) => Promise<{ path: string; branch: string; repoRoot: string }> + worktreeRemove: ( + repoPath: string, + worktreePath: string, + options?: { force?: boolean } + ) => Promise<{ removed: string }> + branchSwitch: (repoPath: string, branch: string) => Promise<{ branch: string }> + // Local branches for the "convert a branch into a worktree" picker. + branchList: (repoPath: string) => Promise + // Compact working-tree status for the composer coding rail. Null on a + // non-repo / remote backend (where the Electron probe can't run). + repoStatus: (repoPath: string) => Promise + // Working-tree-vs-HEAD unified diff for one file (the preview's diff + // view). Empty string when the file is unchanged or not in a repo. + fileDiff: (repoPath: string, filePath: string) => Promise + // Codex-style review pane: changed files per scope, per-file diff, and + // stage / unstage / revert. + review: { + list: (repoPath: string, scope: HermesReviewScope, baseRef?: null | string) => Promise + diff: ( + repoPath: string, + filePath: string, + scope: HermesReviewScope, + baseRef?: null | string, + staged?: boolean + ) => Promise + stage: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }> + unstage: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }> + revert: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }> + revParse: (repoPath: string, ref?: null | string) => Promise + commit: (repoPath: string, message: string, push: boolean) => Promise<{ ok: boolean }> + // Diff (staged-or-all) + recent commit subjects for drafting a + // commit message. Reads only; empty strings off-repo. + commitContext: (repoPath: string) => Promise<{ diff: string; recent: string }> + push: (repoPath: string) => Promise<{ ok: boolean }> + shipInfo: (repoPath: string) => Promise + createPr: (repoPath: string) => Promise<{ url: string }> + } + // Repo-first discovery: scan bounded roots for git repos (depth-capped). + scanRepos: ( + roots: string[], + options?: { maxDepth?: number } + ) => Promise<{ root: string; label: string }[]> + } terminal: { dispose: (id: string) => Promise onData: (id: string, callback: (payload: string) => void) => () => void @@ -511,16 +565,92 @@ export interface HermesPreviewWatch { path: string } -export interface HermesWorktreeInfo { - // Main repo root — the shared grouping key for a checkout and all its linked - // worktrees. - repoRoot: string - // This cwd's own worktree root. - worktreeRoot: string - // True when this is the repo's primary checkout (.git is a directory). - isMainWorktree: boolean - // Current branch (or short detached-HEAD sha), null when unreadable. +// A real git worktree as reported by `git worktree list` (source of truth for +// the "Start work" flow), as opposed to the session-cwd-derived grouping above. +export interface HermesGitWorktree { + path: string branch: null | string + isMain: boolean + detached: boolean + locked: boolean +} + +// A local branch as offered by the "convert a branch into a worktree" picker. +// `checkedOut` marks branches git won't let a second worktree claim. +export interface HermesGitBranch { + name: string + checkedOut: boolean + worktreePath: null | string +} + +// A single changed path from `git status --porcelain=v2`, classified by state +// so the coding rail / switcher can group + open the right diff. +export interface HermesRepoStatusFile { + path: string + staged: boolean + unstaged: boolean + untracked: boolean + conflicted: boolean +} + +// Compact working-tree status for the composer coding rail (parsed from +// `git status --porcelain=v2 --branch`). +export interface HermesRepoStatus { + branch: null | string + // The repo's trunk ("main" / "master" / …), so the UI can offer "branch off + // the default" from anywhere. Null when no trunk is detected. + defaultBranch: null | string + detached: boolean + ahead: number + behind: number + staged: number + unstaged: number + untracked: number + conflicted: number + // Total distinct changed paths (tracked modified + conflicts + untracked). + changed: number + // +/- line counts of tracked changes vs HEAD (staged + unstaged). Untracked + // files aren't in the diff, so they don't contribute lines. + added: number + removed: number + // Capped changed-file list (REPO_STATUS_FILE_CAP) for the diff/open actions. + files: HermesRepoStatusFile[] +} + +// Diff scope for the review pane, mirroring Codex: uncommitted working-tree +// changes, all changes vs the branch base, or everything since the current +// turn began. +export type HermesReviewScope = 'branch' | 'lastTurn' | 'uncommitted' + +// One changed file in the review pane (status letter, +/- lines, staged flag). +export interface HermesReviewFile { + path: string + added: number + removed: number + // M(odified) A(dded) D(eleted) R(enamed) C(opied) U(nmerged) ?(untracked) + status: string + staged: boolean +} + +export interface HermesReviewList { + files: HermesReviewFile[] + // The resolved base ref the scope diffed against (branch merge-base / turn + // baseline), or null for the uncommitted scope. + base: null | string +} + +// The branch's PR (if any) as reported by `gh pr view`. +export interface HermesReviewPr { + url: string + state: string + number: number +} + +// gh availability/auth + the current branch's PR — drives the review pane's PR +// button (disabled when gh isn't ready, "Open PR" vs "Create PR" otherwise). +export interface HermesReviewShipInfo { + ghReady: boolean + pr: HermesReviewPr | null } export interface HermesReadDirEntry { From 344415892f5d1de80fe4141e4ba3dfb76d167124 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH 09/19] feat(desktop): add shared project UI primitives --- apps/desktop/components.json | 2 +- .../assistant-ui/thread-timeline.tsx | 146 ++++-- .../src/components/assistant-ui/thread.tsx | 102 +++- .../assistant-ui/tool-fallback-model.ts | 4 - .../components/assistant-ui/tool-fallback.tsx | 10 +- .../src/components/chat/diff-lines.tsx | 491 +++++++++++++++++- .../src/components/chat/fixed-row-window.ts | 155 ++++++ .../desktop/src/components/chat/skeletons.tsx | 47 ++ .../src/components/chat/status-row.tsx | 10 +- .../src/components/error-boundary.test.tsx | 114 ---- .../desktop/src/components/error-boundary.tsx | 69 --- .../src/components/pane-shell/pane-shell.tsx | 26 +- apps/desktop/src/components/ui/codicon.tsx | 11 + .../src/components/ui/color-swatches.tsx | 50 ++ apps/desktop/src/components/ui/dialog.tsx | 2 +- apps/desktop/src/components/ui/diff-count.tsx | 52 ++ apps/desktop/src/components/ui/input.tsx | 6 + apps/desktop/src/components/ui/popover.tsx | 26 +- .../src/components/ui/sanitized-input.tsx | 17 + .../src/components/ui/split-button.tsx | 98 ++++ apps/desktop/src/components/ui/textarea.tsx | 14 +- apps/desktop/src/hooks/use-delayed-true.ts | 26 + apps/desktop/src/hooks/use-worktree-info.ts | 68 --- apps/desktop/src/lib/chat-messages.ts | 3 + apps/desktop/src/lib/desktop-fs.ts | 75 ++- apps/desktop/src/lib/excluded-paths.ts | 45 ++ apps/desktop/src/lib/icons.ts | 12 +- apps/desktop/src/lib/keybinds/actions.ts | 4 + apps/desktop/src/lib/oneshot.ts | 58 +++ apps/desktop/src/lib/persisted.ts | 78 +++ apps/desktop/src/lib/pool.test.ts | 28 + apps/desktop/src/lib/pool.ts | 20 + .../desktop/src/lib/project-idea-templates.ts | 116 +++++ apps/desktop/src/lib/sanitize.test.ts | 32 ++ apps/desktop/src/lib/sanitize.ts | 21 + .../src/lib/session-branch-tree.test.ts | 53 ++ apps/desktop/src/lib/session-branch-tree.ts | 100 ++++ apps/desktop/src/lib/storage.ts | 116 +++-- 38 files changed, 1867 insertions(+), 440 deletions(-) create mode 100644 apps/desktop/src/components/chat/fixed-row-window.ts create mode 100644 apps/desktop/src/components/chat/skeletons.tsx delete mode 100644 apps/desktop/src/components/error-boundary.test.tsx create mode 100644 apps/desktop/src/components/ui/color-swatches.tsx create mode 100644 apps/desktop/src/components/ui/diff-count.tsx create mode 100644 apps/desktop/src/components/ui/sanitized-input.tsx create mode 100644 apps/desktop/src/components/ui/split-button.tsx create mode 100644 apps/desktop/src/hooks/use-delayed-true.ts delete mode 100644 apps/desktop/src/hooks/use-worktree-info.ts create mode 100644 apps/desktop/src/lib/excluded-paths.ts create mode 100644 apps/desktop/src/lib/oneshot.ts create mode 100644 apps/desktop/src/lib/persisted.ts create mode 100644 apps/desktop/src/lib/pool.test.ts create mode 100644 apps/desktop/src/lib/pool.ts create mode 100644 apps/desktop/src/lib/project-idea-templates.ts create mode 100644 apps/desktop/src/lib/sanitize.test.ts create mode 100644 apps/desktop/src/lib/sanitize.ts create mode 100644 apps/desktop/src/lib/session-branch-tree.test.ts create mode 100644 apps/desktop/src/lib/session-branch-tree.ts diff --git a/apps/desktop/components.json b/apps/desktop/components.json index 3ad19817cdd..545360ae7a2 100644 --- a/apps/desktop/components.json +++ b/apps/desktop/components.json @@ -17,5 +17,5 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "iconLibrary": "tabler" } diff --git a/apps/desktop/src/components/assistant-ui/thread-timeline.tsx b/apps/desktop/src/components/assistant-ui/thread-timeline.tsx index e330cb6d755..f52c27d1adb 100644 --- a/apps/desktop/src/components/assistant-ui/thread-timeline.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-timeline.tsx @@ -4,7 +4,6 @@ import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'reac import { composerPanelCard } from '@/components/chat/composer-dock' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { setPaneHoverRevealSuppressed } from '@/store/panes' import { activeTimelineIndex, @@ -60,6 +59,51 @@ function userPromptText(content: unknown): string { return out } +/** Index-keyed ref-array setter — `ref={listRef(refs, i)}`. */ +const listRef = + (refs: React.RefObject<(T | null)[]>, index: number) => + (node: T | null) => { + refs.current[index] = node + } + +/** Mouse enter/leave pair forwarding `on` to the shared paint(). */ +const hoverProps = (index: number, paint: (index: number, on: boolean) => void) => ({ + onMouseEnter: () => paint(index, true), + onMouseLeave: () => paint(index, false) +}) + +// Constant-duration jump (eased), NOT native `behavior:'smooth'` — Chromium's +// smooth scroll animates proportional to distance, so jumping across a long +// thread crawls for seconds. A fixed ~260ms feels instant near or far. A +// shared rAF handle cancels a prior jump so rapid tick clicks don't fight. +let jumpRaf = 0 + +function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void { + cancelAnimationFrame(jumpRaf) + const start = viewport.scrollTop + const delta = top - start + + if (Math.abs(delta) < 2) { + viewport.scrollTop = top + + return + } + + const t0 = performance.now() + const ease = (t: number) => 1 - (1 - t) ** 3 // easeOutCubic + + const step = (now: number) => { + const p = Math.min(1, (now - t0) / duration) + viewport.scrollTop = start + delta * ease(p) + + if (p < 1) { + jumpRaf = requestAnimationFrame(step) + } + } + + jumpRaf = requestAnimationFrame(step) +} + function scrollToPrompt(id: string) { const viewport = document.querySelector(VIEWPORT) const node = viewport?.querySelector(`[data-message-id="${CSS.escape(id)}"]`) @@ -71,7 +115,7 @@ function scrollToPrompt(id: string) { const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8 triggerHaptic('selection') - viewport.scrollTo({ behavior: 'smooth', top: Math.max(0, top) }) + jumpScroll(viewport, Math.max(0, top)) } /** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */ @@ -96,36 +140,36 @@ export const ThreadTimeline: FC = () => { ) const [activeIndex, setActiveIndex] = useState(0) - const [hoverIndex, setHoverIndex] = useState(null) const [open, setOpen] = useState(false) const closeTimerRef = useRef(undefined) + // Hover sync lives on the DOM, not in React state — the tick and its popover + // row are siblings in different subtrees, so a shared index-keyed paint() lights + // both without a re-render (and without coupling them through a parent atom). + const tickRefs = useRef<(HTMLSpanElement | null)[]>([]) + const rowRefs = useRef<(HTMLButtonElement | null)[]>([]) + + const paint = useCallback((index: number, on: boolean) => { + const tick = tickRefs.current[index] + + if (tick) { + tick.style.opacity = on ? '1' : '' + } + + rowRefs.current[index]?.classList.toggle('bg-(--ui-row-hover-background)', on) + }, []) + const keepOpen = useCallback(() => { window.clearTimeout(closeTimerRef.current) - setPaneHoverRevealSuppressed(true) setOpen(true) }, []) const closeSoon = useCallback(() => { window.clearTimeout(closeTimerRef.current) - setHoverIndex(null) - setPaneHoverRevealSuppressed(false) closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS) }, []) - useEffect( - () => () => { - window.clearTimeout(closeTimerRef.current) - setPaneHoverRevealSuppressed(false) - }, - [] - ) - - useEffect(() => { - if (entries.length < MIN_ENTRIES) { - setPaneHoverRevealSuppressed(false) - } - }, [entries.length]) + useEffect(() => () => window.clearTimeout(closeTimerRef.current), []) useEffect(() => { const viewport = document.querySelector(VIEWPORT) @@ -179,6 +223,7 @@ export const ThreadTimeline: FC = () => { aria-label="Conversation timeline" className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end" data-slot="thread-timeline" + data-suppress-pane-reveal="" onMouseEnter={keepOpen} onMouseLeave={closeSoon} role="navigation" @@ -186,16 +231,17 @@ export const ThreadTimeline: FC = () => { ) @@ -204,11 +250,11 @@ export const ThreadTimeline: FC = () => { const TimelinePopover: FC<{ activeIndex: number entries: TimelineEntry[] - hoverIndex: number | null - onHover: (index: number) => void + onHover: (index: number, on: boolean) => void onJump: (id: string) => void open: boolean -}> = ({ activeIndex, entries, hoverIndex, onHover, onJump, open }) => ( + rowRefs: React.RefObject<(HTMLButtonElement | null)[]> +}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => (
- {entries.map((entry, index) => { - const hovered = index === hoverIndex - const active = index === activeIndex - - return ( - - ) - })} + {entries.map((entry, index) => ( + + ))}
) const TimelineTicks: FC<{ activeIndex: number entries: TimelineEntry[] - onHover: (index: number) => void + onHover: (index: number, on: boolean) => void onJump: (id: string) => void -}> = ({ activeIndex, entries, onHover, onJump }) => ( + tickRefs: React.RefObject<(HTMLSpanElement | null)[]> +}> = ({ activeIndex, entries, onHover, onJump, tickRefs }) => (
{entries.map((entry, index) => ( ))} diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 6057307dec3..66bb707766b 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -11,7 +11,6 @@ import { useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { IconPlayerStopFilled } from '@tabler/icons-react' import { type ClipboardEvent, type ComponentProps, @@ -92,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { LinkifiedText } from '@/lib/external-link' import { triggerHaptic } from '@/lib/haptics' -import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons' +import { GitBranchIcon, Loader2Icon, StopFilled, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' @@ -105,6 +104,10 @@ import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scro import { $voicePlayback } from '@/store/voice-playback' type ThreadLoadingState = 'response' | 'session' +interface RestoreMessageTarget { + text: string + userOrdinal: number | null +} interface MessageActionProps { messageId: string @@ -171,7 +174,7 @@ export const Thread: FC<{ onBranchInNewChat?: (messageId: string) => void onCancel?: () => Promise | void onDismissError?: (messageId: string) => void - onRestoreToMessage?: (messageId: string) => Promise | void + onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise | void sessionId?: string | null sessionKey?: string | null }> = ({ @@ -187,14 +190,45 @@ export const Thread: FC<{ sessionId = null, sessionKey }) => { + const { t } = useI18n() + const copy = t.assistant.thread + + const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<(RestoreMessageTarget & { messageId: string }) | null>( + null + ) + + const closeRestoreConfirm = useCallback(() => setRestoreConfirmTarget(null), []) + + const confirmRestore = useCallback(() => { + if (!restoreConfirmTarget || !onRestoreToMessage) { + throw new Error('Restore is unavailable for this message.') + } + + const { messageId, text, userOrdinal } = restoreConfirmTarget + + closeRestoreConfirm() + void Promise.resolve(onRestoreToMessage(messageId, { text, userOrdinal })).catch((error: unknown) => { + notifyError(error, 'Restore failed') + }) + }, [closeRestoreConfirm, onRestoreToMessage, restoreConfirmTarget]) + + const requestRestoreConfirm = useCallback((messageId: string, target: RestoreMessageTarget) => { + setRestoreConfirmTarget({ messageId, ...target }) + }, []) + const messageComponents = useMemo( () => ({ AssistantMessage: () => , SystemMessage, UserEditComposer: () => , - UserMessage: () => + UserMessage: () => ( + + ) }), - [cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId] + [cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, requestRestoreConfirm, sessionId] ) const emptyPlaceholder = intro ? ( @@ -214,6 +248,15 @@ export const Thread: FC<{ /> {loading === 'session' && } +
) } @@ -844,7 +887,7 @@ const USER_ACTION_ICON_BUTTON_CLASS = 'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70' const USER_ACTION_ICON_SIZE = '0.6875rem' -const StopGlyph = +const StopGlyph = // Background-process notifications are injected into the conversation as user // messages (the agent must react to them, and message-role alternation forbids @@ -884,11 +927,10 @@ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => { const UserMessage: FC<{ onCancel?: () => Promise | void - onRestoreToMessage?: (messageId: string) => Promise | void -}> = ({ onCancel, onRestoreToMessage }) => { + onRequestRestoreConfirm?: (messageId: string, target: RestoreMessageTarget) => void +}> = ({ onCancel, onRequestRestoreConfirm }) => { const { t } = useI18n() const copy = t.assistant.thread - const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) const messageId = useAuiState(s => s.message.id) const content = useAuiState(s => s.message.content) const messageText = messageContentText(content) @@ -906,6 +948,24 @@ const UserMessage: FC<{ return null }) + const runtimeUserOrdinal = useAuiState(s => { + let ordinal = 0 + + for (const message of s.thread.messages) { + if (message.role !== 'user') { + continue + } + + if (message.id === s.message.id) { + return ordinal + } + + ordinal += 1 + } + + return null + }) + const attachmentRefs = useAuiState(s => { const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown } @@ -976,7 +1036,7 @@ const UserMessage: FC<{ // Restore (re-run this exact prompt) is available everywhere the Stop button // isn't — including mid-stream on older prompts, since the action interrupts // the live turn before rewinding. - const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody + const showRestore = !showStop && Boolean(onRequestRestoreConfirm) && hasBody const bubbleClassName = cn( USER_BUBBLE_BASE_CLASS, @@ -1001,7 +1061,6 @@ const UserMessage: FC<{ return ( ) : null } + messageId={messageId} >
@@ -1054,7 +1114,14 @@ const UserMessage: FC<{ event.preventDefault() event.stopPropagation() triggerHaptic('selection') - setRestoreConfirmOpen(true) + onRequestRestoreConfirm?.(messageId, { + text: messageText, + userOrdinal: runtimeUserOrdinal + }) + }} + onPointerDown={event => { + event.preventDefault() + event.stopPropagation() }} title={copy.restoreFromHere} type="button" @@ -1088,17 +1155,6 @@ const UserMessage: FC<{
- {showRestore && ( - setRestoreConfirmOpen(false)} - onConfirm={() => onRestoreToMessage?.(messageId)} - open={restoreConfirmOpen} - title={copy.restoreTitle} - /> - )}
) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 5305c594f96..9a3dcee0a65 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -1291,10 +1291,6 @@ function toolDetailLabel(toolName: string): string { return 'Snapshot summary' } - if (toolName === 'terminal' || toolName === 'execute_code') { - return 'Command output' - } - return '' } diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 2d2eea54e54..599cc2fbbd5 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -71,7 +71,7 @@ const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center // Glass-style section label that sits above any pre/JSON/output block. // Lowercase tracking + tiny size so it reads as a quiet field label rather -// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc. +// than a chrome heading. Used for "stdout", "stderr", "Search results", etc. const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)' // Inset scroll surface for any detail body. The expanded tool row owns the @@ -423,7 +423,7 @@ function ToolEntry({ part }: ToolEntryProps) { return (
)} - {view.inlineDiff && } + {view.inlineDiff && ( + + )} {showDetail && toolViewMode !== 'technical' && (view.status === 'error' ? ( diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx index 767e6029c6e..5f71a4398df 100644 --- a/apps/desktop/src/components/chat/diff-lines.tsx +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -3,8 +3,9 @@ import type { ReactNode } from 'react' import * as React from 'react' import { useShikiHighlighter } from 'react-shiki' -import type { ShikiTransformer } from 'shiki' +import { type BundledLanguage, codeToTokens, type ShikiTransformer, type ThemedToken } from 'shiki' +import { chunkLines, type LineChunk, useFixedRowWindow } from '@/components/chat/fixed-row-window' import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter' import { shikiLanguageForFilename } from '@/lib/markdown-code' import { cn } from '@/lib/utils' @@ -20,9 +21,20 @@ import { cn } from '@/lib/utils' */ type DiffKind = 'add' | 'context' | 'remove' -interface DiffLine { +export interface DiffLine { kind: DiffKind text: string + /** 1-based line number in the old/new file (absent on the "other" side of an + * add/remove, and on hunk-separator blanks). Only used when line numbers are + * shown (the preview's full diff). */ + newNo?: number + oldNo?: number +} + +interface ParsedHunk { + lines: Array<{ kind: DiffKind; text: string }> + newStart: number + oldStart: number } // Tint + 2px gutter accent per change kind. Text color is included for the @@ -41,12 +53,19 @@ const DIFF_KIND_TEXT: Record = { } const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px' +const PREVIEW_DIFF_LINE_BASE = 'block h-5 min-w-max whitespace-pre px-2.5 leading-5' +const PREVIEW_CHUNK_LINES = 200 +const PREVIEW_LINE_PX = 20 +const PREVIEW_OVERSCAN_LINES = 400 // Bleed out of the tool-card body's `p-1.5` so tints/borders run flush to the // card edges (rounded corners clip via the card's overflow); compact height // with internal scroll like a code block. +// `overscroll-y-auto` so reaching the box's top/bottom hands the wheel back to +// the page (no scroll-trap); `overscroll-x-contain` keeps a trackpad's sideways +// overscroll on long code lines from firing browser back/forward navigation. const DIFF_BOX_CLASS = - '-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-contain font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)' + '-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-x-contain overscroll-y-auto font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)' function diffKind(line: string): DiffKind { if (line.startsWith('+') && !line.startsWith('+++')) { @@ -75,7 +94,16 @@ function stripDiffMarker(line: string): string { // arrow line. That preamble just repeats the path (which the tool row already // shows) and reads especially badly for absolute paths (`a//Users/…`). Strip // the leading header zone up to the first hunk. -const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file'] +const DIFF_HEADER_PREFIXES = [ + 'diff --git', + 'index ', + '--- ', + '+++ ', + 'similarity ', + 'rename ', + 'new file', + 'deleted file' +] function isArrowHeaderLine(line: string): boolean { const trimmed = line.trim() @@ -105,23 +133,144 @@ export function stripDiffFileHeaders(diff: string): string { return lines.slice(start).join('\n') } -// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank -// separator kept between hunks), markers stripped, kind recorded. -function parseDiff(diff: string): DiffLine[] { - const out: DiffLine[] = [] - let emitted = false +function parseHunks(diff: string): ParsedHunk[] { + const hunks: ParsedHunk[] = [] + let active: null | ParsedHunk = null for (const line of stripDiffFileHeaders(diff).split('\n')) { if (line.startsWith('@@')) { - if (emitted) { - out.push({ kind: 'context', text: '' }) + const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line) + + if (!match) { + active = null + + continue } + active = { oldStart: Number(match[1]), newStart: Number(match[2]), lines: [] } + hunks.push(active) + continue } - out.push({ kind: diffKind(line), text: stripDiffMarker(line) }) - emitted = true + if (!active || line.startsWith('\\')) { + continue + } + + active.lines.push({ kind: diffKind(line), text: stripDiffMarker(line) }) + } + + return hunks +} + +// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank +// separator kept between hunks), markers stripped, kind recorded. Old/new line +// numbers are tracked from each `@@ -a,b +c,d @@` header so a caller that wants +// a gutter (the preview) can render them; the blank separator carries none. +function parseDiff(diff: string): DiffLine[] { + const hunks = parseHunks(diff) + + if (hunks.length === 0) { + // Fallback for unexpected non-hunk payloads. + return stripDiffFileHeaders(diff) + .split('\n') + .map(line => ({ kind: diffKind(line), text: stripDiffMarker(line) })) + } + + const out: DiffLine[] = [] + let emitted = false + let oldNo = 1 + let newNo = 1 + + for (const hunk of hunks) { + oldNo = hunk.oldStart + newNo = hunk.newStart + + if (emitted) { + out.push({ kind: 'context', text: '' }) + } + + for (const line of hunk.lines) { + const entry: DiffLine = { kind: line.kind, text: line.text } + + if (line.kind === 'add') { + entry.newNo = newNo++ + } else if (line.kind === 'remove') { + entry.oldNo = oldNo++ + } else { + entry.oldNo = oldNo++ + entry.newNo = newNo++ + } + + out.push(entry) + emitted = true + } + } + + return out +} + +// Build a full-file diff view anchored to the CURRENT file text. Every current +// line is emitted from `fullText` with its real new-file line number; hunks only +// mark those rows as added and insert deleted rows between them. That keeps the +// preview's SOURCE and DIFF views on the same line map even when git returns +// compact hunks or removed-only rows. +function parseFullFileDiff(diff: string, fullText: string): DiffLine[] { + const hunks = parseHunks(diff) + const fullLines = fullText.split('\n') + + if (hunks.length === 0) { + return fullLines.map((text, index) => ({ kind: 'context', newNo: index + 1, oldNo: index + 1, text })) + } + + const added = new Set() + const oldNoByNewNo = new Map() + const removalsByNewNo = new Map() + const out: DiffLine[] = [] + + for (const hunk of hunks) { + let oldNo = hunk.oldStart + let newNo = hunk.newStart + + for (const line of hunk.lines) { + if (line.kind === 'add') { + added.add(newNo) + newNo += 1 + } else if (line.kind === 'remove') { + const anchor = Math.max(1, Math.min(newNo, fullLines.length + 1)) + const bucket = removalsByNewNo.get(anchor) ?? [] + + bucket.push({ kind: 'remove', oldNo, text: line.text }) + removalsByNewNo.set(anchor, bucket) + oldNo += 1 + } else { + oldNoByNewNo.set(newNo, oldNo) + oldNo += 1 + newNo += 1 + } + } + } + + for (let index = 0; index < fullLines.length; index += 1) { + const newNo = index + 1 + const removals = removalsByNewNo.get(newNo) + + if (removals) { + out.push(...removals) + } + + out.push({ + kind: added.has(newNo) ? 'add' : 'context', + newNo, + oldNo: oldNoByNewNo.get(newNo), + text: fullLines[index] ?? '' + }) + } + + const trailingRemovals = removalsByNewNo.get(fullLines.length + 1) + + if (trailingRemovals) { + out.push(...trailingRemovals) } return out @@ -142,6 +291,159 @@ function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) { ) } +// shiki FontStyle is a bitmask: Italic=1, Bold=2, Underline=4. +function tokenStyle({ bgColor, color, fontStyle = 0 }: ThemedToken): React.CSSProperties | undefined { + if (!color && !bgColor && !fontStyle) { + return undefined + } + + return { + backgroundColor: bgColor, + color, + fontStyle: fontStyle & 1 ? 'italic' : undefined, + fontWeight: fontStyle & 2 ? 700 : undefined, + textDecorationLine: fontStyle & 4 ? 'underline' : undefined + } +} + +function useThemeName() { + const current = () => (document.documentElement.classList.contains('dark') ? SHIKI_THEME.dark : SHIKI_THEME.light) + const [theme, setTheme] = React.useState(current) + + React.useEffect(() => { + const observer = new MutationObserver(() => setTheme(current())) + + observer.observe(document.documentElement, { attributeFilter: ['class'], attributes: true }) + + return () => observer.disconnect() + }, []) + + return theme +} + +function PreviewDiffRows({ + afterLines = 0, + beforeLines = 0, + chunks, + tokens +}: { + afterLines?: number + beforeLines?: number + chunks: Array> + tokens?: ThemedToken[][] | null +}) { + return ( + <> + {beforeLines > 0 &&
} + {chunks.map(chunk => ( +
+ {chunk.lines.map((line, offset) => { + const index = chunk.start + offset + const rowTokens = tokens?.[index] ?? [] + + return ( + + {rowTokens.length > 0 + ? rowTokens.map((token, tokenIndex) => ( + + {token.content} + + )) + : line.text || ' '} + + ) + })} +
+ ))} + {afterLines > 0 &&
} + + ) +} + +function TokenizedDiffBody({ + afterLines, + beforeLines, + chunked = false, + chunks, + language, + lines +}: { + afterLines?: number + beforeLines?: number + chunked?: boolean + chunks?: Array> + language: string + lines: DiffLine[] +}) { + const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines]) + const theme = useThemeName() + const [tokens, setTokens] = React.useState(null) + + React.useEffect(() => { + let cancelled = false + + setTokens(null) + void codeToTokens(code, { lang: language as BundledLanguage, theme }) + .then(result => { + if (!cancelled) { + setTokens(result.tokens) + } + }) + .catch(() => { + if (!cancelled) { + setTokens([]) + } + }) + + return () => { + cancelled = true + } + }, [code, language, theme]) + + if (!tokens) { + return chunked ? ( + + ) : ( + + ) + } + + if (chunked) { + return ( + + ) + } + + return ( + <> + {lines.map((line, index) => { + const rowTokens = tokens[index] ?? [] + + return ( + + {rowTokens.length > 0 + ? rowTokens.map((token, tokenIndex) => ( + + {token.content} + + )) + : line.text || ' '} + + ) + })} + + ) +} + // Shiki transformer: tag each `.line` with the diff tint for its kind, so the // syntax-highlighted output keeps add/remove backgrounds + the gutter accent. function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer { @@ -187,19 +489,164 @@ export function DiffLines({ className, text, ...props }: DiffLinesProps) { ) } -interface FileDiffPanelProps { - diff: string - path?: string +// Coalesce consecutive same-kind changed rows into runs, each placed by line +// fraction (no DOM measurement). Context rows produce no tick. +function overviewRuns(lines: DiffLine[]): { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] { + const total = lines.length || 1 + const runs: { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] = [] + + for (let i = 0; i < lines.length; ) { + const kind = lines[i].kind + + if (kind === 'context') { + i += 1 + + continue + } + + let j = i + 1 + + while (j < lines.length && lines[j].kind === kind) { + j += 1 + } + + runs.push({ kind, sizePct: ((j - i) / total) * 100, startPct: (i / total) * 100 }) + i = j + } + + return runs } -export function FileDiffPanel({ diff, path }: FileDiffPanelProps) { - const lines = React.useMemo(() => parseDiff(diff), [diff]) - const language = shikiLanguageForFilename(path) - const canHighlight = Boolean(language) && !exceedsHighlightBudget(diff) +// VS Code-style overview ruler: a thin strip pinned to the diff's right edge with +// a green/red tick per change, positioned by line fraction. Pinned to the +// viewport (not the scrolled content) by living as an absolute sibling of the +// scroller inside a relative wrapper — so no scroll listener or measurement. +function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) { + const runs = React.useMemo(() => overviewRuns(lines), [lines]) + + if (runs.length === 0) { + return null + } return ( -
- {canHighlight ? : } +
+ {/* Cap the tick field to the diff's natural height (rows × line px) so a + short diff renders thin, line-aligned ticks instead of stretching a few + changes into gross full-height blocks. A long diff hits the 100% cap and + compresses into a true overview. */} +
+ {runs.map((run, index) => ( +
+ ))} +
+
+ ) +} + +interface FileDiffPanelProps { + /** Override the default (tool-card) box styling — the full-height preview + * cancels the bleed/clamp so the diff fills its pane. */ + className?: string + diff: string + /** Current file text. When provided, the panel expands hunked diffs into a + * full-file view so unchanged lines are preserved between hunks. */ + fullText?: string + path?: string + /** Render an old/new line-number gutter (the full preview diff). The compact + * tool-card + inline review diff leave this off. */ + showLineNumbers?: boolean +} + +export function FileDiffPanel({ className, diff, fullText, path, showLineNumbers = false }: FileDiffPanelProps) { + const lines = React.useMemo( + () => (fullText != null ? parseFullFileDiff(diff, fullText) : parseDiff(diff)), + [diff, fullText] + ) + + const lineChunks = React.useMemo(() => chunkLines(lines, PREVIEW_CHUNK_LINES), [lines]) + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: PREVIEW_OVERSCAN_LINES, + rowPx: PREVIEW_LINE_PX, + rowsPerChunk: PREVIEW_CHUNK_LINES, + totalRows: lines.length + }) + + const visibleLineChunks = lineChunks.slice(startChunk, endChunk + 1) + + const language = shikiLanguageForFilename(path) + const canHighlight = Boolean(language) && !exceedsHighlightBudget(fullText ?? diff) + + // Full-file preview: we own the rows (tokens rendered inside) so blank lines + // can't collapse. Compact tool/review diffs let Shiki own the rows. + const body = !canHighlight ? ( + showLineNumbers ? ( + + ) : ( + + ) + ) : fullText != null ? ( + + ) : ( + + ) + + if (!showLineNumbers) { + return ( +
+ {body} +
+ ) + } + + // A single line-number gutter (VS Code's inline-diff style): each row shows its + // own file's number — the new number for context/adds, the old number for + // removals — with an overview ruler pinned to the right edge. The inner div + // owns the scroll so the ruler (an absolute sibling) stays viewport-fixed. + return ( +
+
+
+
+ {beforeRows > 0 && ( +
+ )} + {visibleLineChunks.map(chunk => ( +
+ {chunk.lines.map((line, offset) => { + const index = chunk.start + offset + + return ( +
+ {line.newNo ?? ''} +
+ ) + })} +
+ ))} + {afterRows > 0 &&
} +
+
{body}
+
+
+
) } diff --git a/apps/desktop/src/components/chat/fixed-row-window.ts b/apps/desktop/src/components/chat/fixed-row-window.ts new file mode 100644 index 00000000000..93e1eefebdc --- /dev/null +++ b/apps/desktop/src/components/chat/fixed-row-window.ts @@ -0,0 +1,155 @@ +import type { RefObject, UIEvent } from 'react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' + +export interface LineChunk { + lines: T[] + start: number +} + +export interface TextLineChunk extends LineChunk { + text: string +} + +interface FixedRowWindowOptions { + overscanRows: number + rowPx: number + rowsPerChunk: number + totalRows: number +} + +export interface FixedRowWindow { + afterRows: number + beforeRows: number + endChunk: number + onScroll: (event: UIEvent) => void + scrollerRef: RefObject + startChunk: number +} + +export function chunkLines(lines: T[], perChunk: number): Array> { + if (lines.length <= perChunk) { + return [{ lines, start: 0 }] + } + + const chunks: Array> = [] + + for (let start = 0; start < lines.length; start += perChunk) { + chunks.push({ lines: lines.slice(start, start + perChunk), start }) + } + + return chunks +} + +export function chunkTextLines(text: string, perChunk: number): TextLineChunk[] { + return chunkLines(text.split('\n'), perChunk).map(chunk => ({ + ...chunk, + text: chunk.lines.join('\n') + })) +} + +type ChunkWindow = Pick + +export function useFixedRowWindow({ + overscanRows, + rowPx, + rowsPerChunk, + totalRows +}: FixedRowWindowOptions): FixedRowWindow { + const scrollerRef = useRef(null) + const rafRef = useRef(null) + + // Derive the visible chunk window from a node's scroll geometry. Pure so we + // can compare results and skip a re-render unless the window actually moved. + const compute = useCallback( + (node: HTMLDivElement | null): ChunkWindow => { + const height = node?.clientHeight || 800 + const scrollTop = node?.scrollTop ?? 0 + const firstRow = Math.max(0, Math.floor(scrollTop / rowPx) - overscanRows) + const lastRow = Math.min(totalRows, Math.ceil((scrollTop + height) / rowPx) + overscanRows) + const startChunk = Math.floor(firstRow / rowsPerChunk) + const endChunk = Math.max(startChunk, Math.floor(Math.max(firstRow, lastRow - 1) / rowsPerChunk)) + + return { + afterRows: Math.max(0, totalRows - Math.min(totalRows, (endChunk + 1) * rowsPerChunk)), + beforeRows: Math.min(totalRows, startChunk * rowsPerChunk), + endChunk, + startChunk + } + }, + [overscanRows, rowPx, rowsPerChunk, totalRows] + ) + + const [win, setWin] = useState(() => compute(null)) + + // Only commit a new window when a boundary is crossed — scrolling within the + // current chunk span (the common case, every rAF) keeps the same object and + // re-renders nothing. + const sync = useCallback( + (node: HTMLDivElement | null = scrollerRef.current) => { + if (!node) { + return + } + + const next = compute(node) + + setWin(prev => + prev.startChunk === next.startChunk && + prev.endChunk === next.endChunk && + prev.beforeRows === next.beforeRows && + prev.afterRows === next.afterRows + ? prev + : next + ) + }, + [compute] + ) + + const cancelFrame = useCallback(() => { + if (rafRef.current == null) { + return + } + + cancelAnimationFrame(rafRef.current) + rafRef.current = null + }, []) + + const onScroll = useCallback( + (event: UIEvent) => { + const node = event.currentTarget + + cancelFrame() + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + sync(node) + }) + }, + [cancelFrame, sync] + ) + + // Re-sync on mount, on resize, and whenever the row geometry changes (new + // file/diff → `compute` identity changes → effect re-runs). + useLayoutEffect(() => { + const node = scrollerRef.current + + if (!node) { + return + } + + sync(node) + + if (typeof ResizeObserver === 'undefined') { + return cancelFrame + } + + const observer = new ResizeObserver(() => sync(node)) + + observer.observe(node) + + return () => { + observer.disconnect() + cancelFrame() + } + }, [cancelFrame, sync]) + + return { ...win, onScroll, scrollerRef } +} diff --git a/apps/desktop/src/components/chat/skeletons.tsx b/apps/desktop/src/components/chat/skeletons.tsx new file mode 100644 index 00000000000..b6dcfdab341 --- /dev/null +++ b/apps/desktop/src/components/chat/skeletons.tsx @@ -0,0 +1,47 @@ +import type { CSSProperties } from 'react' + +import { Skeleton } from '@/components/ui/skeleton' + +// Shared loading skeletons for the file/git trees and diffs — quieter than a +// spinner and shaped like the content that's about to land. + +const TREE_ROWS: { indent: number; width: string }[] = [ + { indent: 0, width: '55%' }, + { indent: 1, width: '72%' }, + { indent: 1, width: '46%' }, + { indent: 0, width: '60%' }, + { indent: 1, width: '52%' }, + { indent: 2, width: '40%' }, + { indent: 0, width: '64%' } +] + +/** Rows of icon + label bars, mimicking a file tree mid-load. */ +export function TreeSkeleton() { + return ( +
+ {TREE_ROWS.map((row, index) => ( +
+ + +
+ ))} +
+ ) +} + +const DIFF_ROWS: string[] = ['72%', '40%', '88%', '55%', '64%', '30%', '80%', '48%', '60%', '36%', '70%'] + +/** Stacked line bars, mimicking a unified diff mid-load. */ +export function DiffSkeleton({ style }: { style?: CSSProperties }) { + return ( +
+ {DIFF_ROWS.map((width, index) => ( + + ))} +
+ ) +} diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx index ad4769c458f..af77fc910ce 100644 --- a/apps/desktop/src/components/chat/status-row.tsx +++ b/apps/desktop/src/components/chat/status-row.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react' +import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { cn } from '@/lib/utils' @@ -8,8 +8,10 @@ interface StatusRowProps { /** Leading glyph slot (spinner / status dot / selection circle). */ leading?: ReactNode /** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y). - * Trailing-slot buttons should `stopPropagation` so they don't also fire it. */ - onActivate?: () => void + * Receives the originating event so consumers can branch on modifier keys + * (e.g. ⌘/Ctrl-click). Trailing-slot buttons should `stopPropagation` so + * they don't also fire it. */ + onActivate?: (event: KeyboardEvent | MouseEvent) => void /** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */ trailing?: ReactNode trailingVisible?: boolean @@ -43,7 +45,7 @@ export function StatusRow({ ? event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - onActivate() + onActivate(event) } } : undefined diff --git a/apps/desktop/src/components/error-boundary.test.tsx b/apps/desktop/src/components/error-boundary.test.tsx deleted file mode 100644 index cd5f3a547bb..00000000000 --- a/apps/desktop/src/components/error-boundary.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { ErrorBoundary } from './error-boundary' - -// The real assistant-ui stale-index throw the root boundary must survive -// (open chat / session switch render race), reproduced verbatim so the -// recoverable-pattern match is exercised against the actual error text. -const TAP_ERROR = 'tapClientLookup: Index 23 out of bounds (length: 18)' - -// Throws purely from `box.error` so a render replay (React dev) throws -// identically; the test mutates the box only from timers, never during render — -// modelling a transient race that clears once the boundary remounts against -// fresh state. -function makeBomb(box: { error: Error | null }) { - return function Bomb() { - if (box.error) { - throw box.error - } - - return
recovered
- } -} - -const RELOAD_WINDOW = { name: 'Reload window', role: 'button' } as const - -const countRecoverWarnings = (calls: unknown[][]) => - calls.filter(call => call.some(value => String(value).includes('auto-recovering from transient render error'))).length - -describe('ErrorBoundary root auto-recovery', () => { - afterEach(() => { - cleanup() - vi.restoreAllMocks() - }) - - it('recovers the root boundary from a transient stale-index render race', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const box: { error: Error | null } = { error: new Error(TAP_ERROR) } - const Bomb = makeBomb(box) - - // Disarm before the scheduled next-tick reset re-renders the subtree, so the - // race genuinely resolves on recovery instead of throwing forever. - queueMicrotask(() => { - box.error = null - }) - - render( - - - - ) - - await waitFor(() => expect(screen.getByText('recovered')).toBeTruthy()) - expect(screen.queryByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeNull() - expect(countRecoverWarnings(warnSpy.mock.calls)).toBeGreaterThanOrEqual(1) - }) - - it('stops auto-recovering a persistent error after the cap and leaves the fallback up', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - // Never disarmed: the boundary must not spin a reset -> throw -> reset loop - // forever — it caps recovery and surfaces the fallback to the user. - const box: { error: Error | null } = { error: new Error(TAP_ERROR) } - const Bomb = makeBomb(box) - - render( - - - - ) - - // The fallback showing up at all IS the cap working: with unbounded recovery - // the boundary would reset -> throw -> reset forever and 'Reload window' - // would never render (this waitFor would hang). The recovery attempts are - // bounded by MAX_RECOVERIES (3), never an unbounded storm. - await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy()) - const warnings = countRecoverWarnings(warnSpy.mock.calls) - expect(warnings).toBeGreaterThanOrEqual(1) - expect(warnings).toBeLessThanOrEqual(3) - }) - - it('does not auto-recover a non-root boundary', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const box: { error: Error | null } = { error: new Error(TAP_ERROR) } - const Bomb = makeBomb(box) - - render( -
scoped-fallback
} label="thread"> - -
- ) - - await waitFor(() => expect(screen.getByText('scoped-fallback')).toBeTruthy()) - expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0) - }) - - it('does not auto-recover an unrecognized error even at the root', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const box: { error: Error | null } = { error: new Error('some unrelated application error') } - const Bomb = makeBomb(box) - - render( - - - - ) - - await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy()) - expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0) - }) -}) diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx index f607760427f..87b6b7743c5 100644 --- a/apps/desktop/src/components/error-boundary.tsx +++ b/apps/desktop/src/components/error-boundary.tsx @@ -20,31 +20,8 @@ interface ErrorBoundaryState { error: Error | null } -// assistant-ui can momentarily render a stale message index against a thread -// that just shrank (session switch / teardown), throwing a render-race error -// that latches the WHOLE app on the root "Reload window" screen. These throws -// clear themselves on the next render against fresh state, so the root boundary -// recovers itself once the storm settles instead of stranding the user. -const RECOVERABLE_ERROR_PATTERNS = [ - /tapClientLookup: Index \d+\s+out of bounds \(length:\s*\d+\)/i, - /Cannot read properties of undefined \(reading 'type'\)/i, - /Tried to unmount a fiber that is already unmounted/i -] - -const isRecoverableDesktopRenderError = (error: Error): boolean => - RECOVERABLE_ERROR_PATTERNS.some(pattern => pattern.test(error.message)) - -// Bound auto-recovery so a *persistent* (non-transient) error can't spin the -// boundary in a reset -> throw -> reset loop: at most MAX_RECOVERIES attempts -// inside RECOVERY_WINDOW_MS, after which the fallback is left up for the user. -const MAX_RECOVERIES = 3 -const RECOVERY_WINDOW_MS = 5_000 - export class ErrorBoundary extends Component { state: ErrorBoundaryState = { error: null } - private recoverTimer: null | number = null - private recoverCount = 0 - private recoverWindowStart = 0 static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { error } @@ -54,55 +31,9 @@ export class ErrorBoundary extends Component { - this.clearRecoverTimer() - // A manual retry (button) starts a clean recovery budget. - this.recoverCount = 0 - this.recoverWindowStart = 0 - this.setState({ error: null }) - } - - // True while the boundary still has recovery budget. Each storm gets a fresh - // window; auto-recovery (autoReset) deliberately does NOT reset the count, so - // a tight reset -> throw loop is capped at MAX_RECOVERIES and then falls back. - private canRecover(): boolean { - const now = Date.now() - - if (now - this.recoverWindowStart > RECOVERY_WINDOW_MS) { - this.recoverWindowStart = now - this.recoverCount = 0 - } - - this.recoverCount += 1 - - return this.recoverCount <= MAX_RECOVERIES - } - - private clearRecoverTimer() { - if (this.recoverTimer !== null) { - window.clearTimeout(this.recoverTimer) - this.recoverTimer = null - } - } - - private scheduleRecover() { - this.clearRecoverTimer() - this.recoverTimer = window.setTimeout(this.autoReset, 0) - } - - private autoReset = () => { - this.recoverTimer = null this.setState({ error: null }) } diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx index 804d560880c..25ca6d03e41 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -15,7 +15,7 @@ import { } from 'react' import { cn } from '@/lib/utils' -import { $paneHoverRevealSuppressed, $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes' +import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes' import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' @@ -38,6 +38,8 @@ export interface PaneProps { forceCollapsed?: boolean /** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */ hoverReveal?: boolean + /** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */ + overlayWidth?: WidthValue /** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */ onOverlayActiveChange?: (overlayActive: boolean) => void id: string @@ -227,7 +229,7 @@ export function PaneShell({ children, className, style }: PaneShellProps) { return ( -
+
{children}
@@ -241,6 +243,7 @@ export function Pane({ divider = false, disabled = false, hoverReveal = false, + overlayWidth: overlayWidthProp, id, maxWidth, minWidth, @@ -250,7 +253,6 @@ export function Pane({ }: PaneProps) { const ctx = useContext(PaneShellContext) const paneStates = useStore($paneStates) - const hoverRevealSuppressed = useStore($paneHoverRevealSuppressed) const registered = useRef(false) const paneRef = useRef(null) // Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS. @@ -263,7 +265,14 @@ export function Pane({ // hover/focus instead of hiding them. Honors any persisted resize width. const overlayActive = !open && hoverReveal && !disabled const override = resizable ? paneStates[id]?.widthOverride : undefined - const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH) + // Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins, + // else the persisted resize override, else the docked width. + const overlayWidth = + overlayWidthProp !== undefined + ? widthToCss(overlayWidthProp, DEFAULT_WIDTH) + : override !== undefined + ? `${override}px` + : widthToCss(width, DEFAULT_WIDTH) useEffect(() => { if (registered.current) { @@ -379,10 +388,8 @@ export function Pane({ >