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/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/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index 731ae56edabd..067a47586388 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -263,6 +263,62 @@ 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_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 09a1774167fd..b14fb079a6a2 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2086,6 +2086,28 @@ 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 + # 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: """Resolve a session cwd that points at a now-deleted directory. @@ -2184,12 +2206,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 +2307,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). @@ -13691,7 +13720,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)