fix(desktop): rank projects by real activity, not disk-scan time

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 01:20:23 -05:00
parent 92549c9a6e
commit 19025eb7ce
6 changed files with 141 additions and 4 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

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

View file

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