Merge pull request #71800 from NousResearch/bb/project-sort

fix(desktop): sort projects by real activity and record terminal session cwd
This commit is contained in:
brooklyn! 2026-07-26 02:06:00 -05:00 committed by GitHub
commit 6deb92df52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 230 additions and 15 deletions

View file

@ -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

View file

@ -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'

View file

@ -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'])
})
})

View file

@ -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.

View file

@ -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}
]

View file

@ -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()

View file

@ -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)