diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 59bfceba0..854ca3532 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 0f51f91a9..68289dff8 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 0c77d9c21..3d22c06eb 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 1d727132a..e75aa467c 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")