mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
commit
6deb92df52
7 changed files with 230 additions and 15 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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue