mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
92549c9a6e
commit
19025eb7ce
6 changed files with 141 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
56
apps/desktop/src/app/chat/sidebar/projects/model.test.ts
Normal file
56
apps/desktop/src/app/chat/sidebar/projects/model.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue