From 19025eb7ce4bf8f264512b166b67c3d73312989b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 01:20:23 -0500 Subject: [PATCH 1/3] fix(desktop): rank projects by real activity, not disk-scan time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar's project overview put git checkouts with zero Hermes sessions above the repos the user actually works in, and dragging a project into place did not stick. Two causes. The repo discovery payload folded `discovered_repos.last_seen` into `last_active`, but `last_seen` is when the disk scan last saw the directory, so every scanned checkout was stamped "just now" and outranked real work. Activity is now session-derived only; a repo with no sessions reports no activity. The overview also applied the manual drag-order through `orderByIds`, which floats every id missing from the saved order to the top. That is right for sessions, where a new chat should not sink, but the overview keeps receiving newly-scanned repos — so once the user dragged anything, each new discovery jumped above their hand-picked list. Projects the user has not ordered now keep their deterministic position: ones with real activity still surface on top, zero-session discoveries sort below the ordered list. --- apps/desktop/src/app/chat/sidebar/index.tsx | 6 +- .../src/app/chat/sidebar/projects/index.ts | 8 ++- .../app/chat/sidebar/projects/model.test.ts | 56 +++++++++++++++++++ .../src/app/chat/sidebar/projects/model.ts | 36 ++++++++++++ tests/tui_gateway/test_projects_rpc.py | 32 +++++++++++ tui_gateway/server.py | 7 ++- 6 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/projects/model.test.ts diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index ef39582e96aa..91dec1e2d58f 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -115,6 +115,7 @@ import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } import { ProfileRail } from './profile-switcher' import { ProjectDialog } from './project-dialog' import { + orderProjectsByIds, overlayLiveLanes, overlayLivePreviews, PROJECT_PREVIEW_COUNT, @@ -622,8 +623,9 @@ export function ChatSidebar({ ) // Layer the user's manual drag-order on top of the deterministic sort. Empty - // (default) returns `sorted` untouched; new projects surface on top. - return orderByIds(sorted, project => project.id, projectOrderIds) + // (default) returns `sorted` untouched; projects the user hasn't ordered yet + // keep their sorted position rather than jumping the hand-picked list. + return orderProjectsByIds(sorted, projectOrderIds) }, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds]) // The overview only renders in grouped mode; the model stays live regardless diff --git a/apps/desktop/src/app/chat/sidebar/projects/index.ts b/apps/desktop/src/app/chat/sidebar/projects/index.ts index dea53d1fe6f0..f30d075a0f50 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/index.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/index.ts @@ -1,6 +1,12 @@ // Public surface of the project/worktree sidebar, consumed by the sidebar root. export { EnteredProjectContent } from './entered-content' -export { PROJECT_PREVIEW_COUNT, projectTreeCwd, sortProjectsForOverview, useRepoWorktreeMap } from './model' +export { + orderProjectsByIds, + PROJECT_PREVIEW_COUNT, + projectTreeCwd, + sortProjectsForOverview, + useRepoWorktreeMap +} from './model' export { ProjectBackRow, ProjectOverviewRow } from './overview-row' export { ProjectMenu } from './project-menu' export { SidebarWorkspaceGroup } from './workspace-group' diff --git a/apps/desktop/src/app/chat/sidebar/projects/model.test.ts b/apps/desktop/src/app/chat/sidebar/projects/model.test.ts new file mode 100644 index 000000000000..c56912fc8628 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/model.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { orderProjectsByIds } from './model' +import type { SidebarProjectTree } from './workspace-groups' + +function makeProject(id: string, sessionCount: number): SidebarProjectTree { + return { + id, + isAuto: true, + label: id, + lastActive: 0, + path: `/repos/${id}`, + previewSessions: [], + repos: [], + sessionCount + } +} + +const ids = (projects: SidebarProjectTree[]) => projects.map(project => project.id) + +describe('orderProjectsByIds', () => { + it('leaves the deterministic sort alone when nothing has been dragged', () => { + const projects = [makeProject('a', 0), makeProject('b', 2)] + + expect(orderProjectsByIds(projects, [])).toBe(projects) + }) + + it('applies the saved manual order', () => { + const projects = [makeProject('a', 1), makeProject('b', 1), makeProject('c', 1)] + + expect(ids(orderProjectsByIds(projects, ['c', 'a', 'b']))).toEqual(['c', 'a', 'b']) + }) + + it('keeps freshly-scanned zero-session repos below the hand-ordered list', () => { + // The regression: a disk scan keeps finding git checkouts the user has + // never opened in Hermes. Surfacing every unsaved id at the top buried the + // projects they deliberately dragged into place. + const projects = [makeProject('scanned-1', 0), makeProject('mine', 4), makeProject('scanned-2', 0)] + + expect(ids(orderProjectsByIds(projects, ['mine']))).toEqual(['mine', 'scanned-1', 'scanned-2']) + }) + + it('still surfaces a new project that has real activity', () => { + // A project you just started working in should not sink beneath the saved + // order — only the zero-session discoveries do. + const projects = [makeProject('ordered', 1), makeProject('just-started', 3)] + + expect(ids(orderProjectsByIds(projects, ['ordered']))).toEqual(['just-started', 'ordered']) + }) + + it('drops ids that are no longer present', () => { + const projects = [makeProject('a', 1)] + + expect(ids(orderProjectsByIds(projects, ['gone', 'a']))).toEqual(['a']) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/projects/model.ts b/apps/desktop/src/app/chat/sidebar/projects/model.ts index 1e55db2fe41e..9e669f13e135 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/model.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/model.ts @@ -75,6 +75,42 @@ export function sortProjectsForOverview( }) } +// Layer the user's manual drag-order over the deterministic sort. +// +// This can't just be `orderByIds`: that surfaces every id missing from the saved +// order at the TOP, which is right for sessions (a new chat should not sink) but +// wrong here. The overview also lists repos found by the disk scan that have +// zero Hermes sessions, and those arrive continuously — so once the user dragged +// anything, every freshly-scanned checkout jumped above the projects they +// actually work in. +// +// Fresh projects keep their place in the deterministic sort instead: ones with +// real activity go on top (a project you just started still surfaces), and +// zero-session discoveries sink below the hand-ordered list. +export function orderProjectsByIds( + projects: SidebarProjectTree[], + orderIds: string[] +): SidebarProjectTree[] { + if (!orderIds.length) { + return projects + } + + const byId = new Map(projects.map(project => [project.id, project])) + const ordered = orderIds.map(id => byId.get(id)).filter((p): p is SidebarProjectTree => Boolean(p)) + const seen = new Set(ordered.map(project => project.id)) + const fresh = projects.filter(project => !seen.has(project.id)) + + if (!fresh.length) { + return ordered + } + + return [ + ...fresh.filter(project => project.sessionCount > 0), + ...ordered, + ...fresh.filter(project => project.sessionCount <= 0) + ] +} + // Project drill-in lanes are git-driven: source them from `git worktree list` so // linked worktrees still appear even when their sessions aren't in the recents // payload currently loaded in memory. diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index 731ae56edabd..1acb712dff2c 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -263,6 +263,38 @@ def test_record_repos_persists_and_shows_zero_session_repo(tmp_path): assert by_label["fresh-repo"]["sessions"] == 0 +def test_scan_time_is_not_treated_as_session_activity(tmp_path): + """A scanned repo with no sessions must not rank as recently active. + + ``discovered_repos.last_seen`` records when the disk scan last saw the + directory. Folding it into ``last_active`` stamped every scanned checkout + with the scan time — i.e. "just now" — so repos the user has never opened + in Hermes outranked the ones they actually work in. + """ + worked_in = tmp_path / "worked-in" + worked_in.mkdir() + subprocess.run(["git", "init"], cwd=worked_in, check=True, capture_output=True) + server._get_db().create_session("worked-in-session", "cli", cwd=str(worked_in)) + + never_opened = tmp_path / "never-opened" + never_opened.mkdir() + + _call( + "projects.record_repos", + {"repos": [{"root": str(never_opened)}, {"root": str(worked_in)}]}, + ) + + by_root = {r["root"]: r for r in _call("projects.discover_repos")["repos"]} + idle = by_root[str(never_opened)] + active = by_root[str(worked_in)] + + assert idle["sessions"] == 0 + # A repo with no sessions has no activity to report... + assert idle["last_active"] == 0 + # ...so the repo the user actually worked in sorts ahead of it. + assert active["last_active"] > idle["last_active"] + + def test_disabled_discovery_clears_cache_and_rejects_new_scan(monkeypatch, tmp_path): repo = tmp_path / "cached-repo" repo.mkdir() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 16daf7b9676d..7375ab542cf4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -13691,7 +13691,12 @@ def _discover_repos_payload( 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)) + # NOTE: `last_seen` is when the disk scan last saw the directory, + # not when the user last worked in it. Folding it into + # `last_active` stamped every scanned repo with the scan time — + # i.e. "just now" — so a git checkout with zero Hermes sessions + # outranked the repos the user actually works in. Activity stays + # session-derived; a repo with no sessions has no activity. if conn is not None: _read(conn) From 3172b8739eb482e308fcba9a6a11d34a44c75edf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 01:20:35 -0500 Subject: [PATCH 2/3] fix(gateway): record a terminal session's working directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions started from the terminal were stored with no `cwd` and no `git_repo_root`, so the sidebar had nothing to group them by and they never appeared under their project — they fell into "No workspace" instead. On one real profile this covered 690 of 1063 TUI sessions. The row write persisted a cwd only when `explicit_cwd` was set, which happens only if the user switches directory mid-session. The intent was to avoid filing chats under whatever folder the app launched in, and that reasoning holds for the desktop, whose launch directory is an artifact of how the bundle was opened. It does not hold for a terminal session: the user cd'd into that directory before running hermes, and it is where the agent's terminal runs. Split the two cases behind one helper. An explicit pick is always persisted; otherwise the launch directory is recorded for terminal-started sessions and left unset for the desktop, preserving the existing "No workspace" default there. Existing rows are unaffected: they carry neither cwd nor git_repo_root, so there is nothing to recover them from. --- tests/tui_gateway/test_projects_rpc.py | 24 ++++++++++++++++ tui_gateway/server.py | 40 +++++++++++++++++++++----- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index 1acb712dff2c..067a47586388 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -295,6 +295,30 @@ def test_scan_time_is_not_treated_as_session_activity(tmp_path): assert active["last_active"] > idle["last_active"] +def test_terminal_session_persists_its_launch_cwd(): + """A terminal session's cwd IS its workspace, so the row must record it. + + The user cd'd into that directory before running hermes. Dropping it left + the row with no cwd and no git_repo_root, so the sidebar could never place + the session under its project. + """ + for source in ("tui", "cli"): + assert server._persisted_session_cwd( + {"source": source, "cwd": "/somewhere/a-repo"} + ) == "/somewhere/a-repo" + + +def test_desktop_launch_cwd_is_not_persisted_as_a_workspace(): + # The desktop launches from wherever the bundle was opened, so an unpicked + # cwd is an artifact — those chats belong under "No workspace". + assert server._persisted_session_cwd({"source": "desktop", "cwd": "/opt/whatever"}) is None + + # An explicit pick is always honored, desktop included. + assert server._persisted_session_cwd( + {"source": "desktop", "cwd": "/picked/repo", "explicit_cwd": True} + ) == "/picked/repo" + + def test_disabled_discovery_clears_cache_and_rejects_new_scan(monkeypatch, tmp_path): repo = tmp_path / "cached-repo" repo.mkdir() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7375ab542cf4..6cffc43e48ee 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2086,6 +2086,25 @@ def _session_cwd(session: dict | None) -> str: return _completion_cwd() +# Sources whose launch directory is an artifact of how the app was started, not +# a workspace the user picked. Everything else is terminal-started: the process +# runs in a directory the user deliberately cd'd into. +_LAUNCH_CWD_NOT_A_WORKSPACE = {"desktop"} + + +def _persisted_session_cwd(session: dict) -> str | None: + """The cwd to stamp on the session's DB row, or None to leave it unset. + + See :func:`_ensure_session_db_row` for why the launch directory counts as a + workspace for terminal sessions but not for the desktop. + """ + if session.get("explicit_cwd"): + return _session_cwd(session) + if _session_source(session) in _LAUNCH_CWD_NOT_A_WORKSPACE: + return None + return _session_cwd(session) or None + + def _heal_dead_cwd(cwd: str) -> str: """Resolve a session cwd that points at a now-deleted directory. @@ -2184,12 +2203,19 @@ def _ensure_session_db_row(session: dict) -> None: Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own lazy create) are no-ops. - Only an *explicitly chosen* workspace is persisted as the session's cwd. - The agent still runs in the auto-detected directory (session["cwd"]), but - we don't stamp that onto the row — otherwise every session the user never - picked a folder for gets grouped under whatever directory the desktop - happened to launch in (e.g. "desktop"). Leaving it null groups them under - "No workspace", which is the desired default. + A cwd the user *chose* is always persisted. When they made no explicit + choice the launch directory stands in, and whether that is meaningful + depends on how the session was started: + + * The desktop launches from wherever the app bundle was opened (often ``/`` + or the user's home), so stamping that would file every unpicked chat under + a folder the user never chose. Those stay null and group under "No + workspace", which is the desired default. + * A terminal session (``hermes`` / ``hermes --tui`` / CLI) is started from a + directory the user deliberately ``cd``'d into — that IS the workspace, and + it is also where the agent's terminal actually runs. Dropping it stranded + the session with no cwd AND no git_repo_root, so the sidebar could never + place it under its project. """ key = session.get("session_key") if not key: @@ -2278,7 +2304,7 @@ def _ensure_session_db_row(session: dict) -> None: 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, + cwd=_persisted_session_cwd(session), # Self-describing rows: aggregators that merge multiple profile DBs # into one list can't rely on which file a row came from alone. NULL # means the launch/default profile (matches run_agent's convention). From ad81e3c16fee91add7623f86bfe0369aca3aa139 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 01:33:20 -0500 Subject: [PATCH 3/3] fix(gateway): only stamp a session's own directory on its row Two existing cases in tests/test_tui_gateway_server.py covered the row's cwd contract and this change had to answer both. `_persisted_session_cwd` reached through `_session_cwd`, which falls back to the gateway-wide completion cwd when the session carries none. That belongs to no session in particular, so a session that never had a directory was given one. Read the session's own `cwd` instead. The remaining case asserted that a terminal session's directory is discarded, which is the behavior this branch deliberately changes. Split it in two: a terminal session now records its workspace, and the desktop keeps the "No workspace" default it was written to protect. --- tests/test_tui_gateway_server.py | 33 ++++++++++++++++++++++++++++---- tui_gateway/server.py | 5 ++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4661f13040d4..7b7d6b2a6d65 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4066,9 +4066,13 @@ def test_ensure_session_db_row_persists_session_source(monkeypatch): ] -def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): - """Without an explicit workspace, cwd is left null so the session groups - under "No workspace" rather than the gateway's launch directory.""" +def test_ensure_session_db_row_records_a_terminal_workspace(monkeypatch, tmp_path): + """A terminal session's directory IS its workspace, so the row records it. + + The user cd'd there before running hermes. Leaving it null stranded the row + with no cwd and no git_repo_root, so the sidebar could never place the + session under its project. + """ created = [] class _FakeDB: @@ -4085,7 +4089,28 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)}) assert created == [ - {"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": None} + {"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": str(tmp_path)} + ] + + +def test_ensure_session_db_row_defaults_desktop_to_no_workspace(monkeypatch, tmp_path): + """The desktop launches from wherever the bundle was opened, so an unpicked + cwd is an artifact — those chats stay null and group under "No workspace".""" + created = [] + + class _FakeDB: + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): + created.append( + {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} + ) + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + + server._ensure_session_db_row({"session_key": "k1", "source": "desktop", "cwd": str(tmp_path)}) + + assert created == [ + {"key": "k1", "source": "desktop", "model": "test-model", "model_config": None, "cwd": None} ] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6cffc43e48ee..9db57df8dd98 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2102,7 +2102,10 @@ def _persisted_session_cwd(session: dict) -> str | None: return _session_cwd(session) if _session_source(session) in _LAUNCH_CWD_NOT_A_WORKSPACE: return None - return _session_cwd(session) or None + # Only the session's OWN directory. `_session_cwd` falls back to the + # gateway-wide completion cwd, which belongs to no session in particular — + # stamping that would invent a workspace for a session that never had one. + return str(session.get("cwd") or "") or None def _heal_dead_cwd(cwd: str) -> str: