mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(gateway): group unplaced sessions into a Home bucket in the project tree
Sessions with no cwd — or whose folder can't be promoted to a project (the bare home dir, a deleted workspace, HERMES state) — were dropped from the project tree entirely, so the grouped sidebar silently showed fewer chats than flat Recents. Collect them into a synthetic `__no_project__` node at the head of the list. It carries one lane purely to hold the rows, and is omitted when empty so a project-less install stays blank.
This commit is contained in:
parent
551e1c6d64
commit
60b6ea237f
2 changed files with 146 additions and 19 deletions
|
|
@ -58,6 +58,26 @@ def _lane_ids(project):
|
|||
return [g["id"] for repo in project["repos"] for g in repo["groups"]]
|
||||
|
||||
|
||||
def _home(tree):
|
||||
"""The Home bucket, or None when nothing was left unplaced."""
|
||||
return next((p for p in tree["projects"] if p["id"] == pt.NO_PROJECT_ID), None)
|
||||
|
||||
|
||||
def _home_session_ids(tree):
|
||||
home = _home(tree)
|
||||
return [s["id"] for s in _sessions_of(home)] if home else []
|
||||
|
||||
|
||||
def _sessions_of(project):
|
||||
return [s for repo in project["repos"] for g in repo["groups"] for s in g["sessions"]]
|
||||
|
||||
|
||||
def _real_project_ids(tree):
|
||||
"""Project ids excluding the Home bucket (which is always present when any
|
||||
session went unplaced, so asserting on it in every test would be noise)."""
|
||||
return [p["id"] for p in tree["projects"] if p["id"] != pt.NO_PROJECT_ID]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -306,12 +326,12 @@ def test_scoped_session_ids_is_union_of_placed_sessions():
|
|||
)
|
||||
owned = _session("/www/app", branch="main")
|
||||
auto = _session("/www/repo", branch="main")
|
||||
homeless = _session(None) # no cwd -> belongs to no project
|
||||
homeless = _session(None) # no cwd -> the Home bucket
|
||||
|
||||
tree = pt.build_tree([project], [owned, auto, homeless], [], resolve, hydrate=True)
|
||||
|
||||
assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"]}
|
||||
assert homeless["id"] not in tree["scoped_session_ids"]
|
||||
assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"], homeless["id"]}
|
||||
assert _home_session_ids(tree) == [homeless["id"]]
|
||||
|
||||
|
||||
def test_overview_drops_session_rows_but_keeps_counts_and_previews():
|
||||
|
|
@ -403,8 +423,8 @@ def test_nested_project_folders_pick_the_deepest_match():
|
|||
|
||||
def test_junk_root_never_becomes_an_auto_project():
|
||||
# A session whose git root is HERMES_HOME (config/state) must not spawn a
|
||||
# phantom project; it falls through to flat Recents (unscoped). A real repo
|
||||
# alongside it still groups normally.
|
||||
# phantom project; it lands in the Home bucket. A real repo alongside it
|
||||
# still groups normally.
|
||||
resolve = _resolver(
|
||||
{
|
||||
"/home/me/.hermes": ("/home/me/.hermes", "/home/me/.hermes"),
|
||||
|
|
@ -417,9 +437,8 @@ def test_junk_root_never_becomes_an_auto_project():
|
|||
|
||||
tree = pt.build_tree([], [junk, real], [], resolve, hydrate=True, is_junk_root=is_junk)
|
||||
|
||||
ids = {p["id"] for p in tree["projects"]}
|
||||
assert ids == {"/www/app"}
|
||||
assert junk["id"] not in tree["scoped_session_ids"]
|
||||
assert _real_project_ids(tree) == ["/www/app"]
|
||||
assert _home_session_ids(tree) == [junk["id"]]
|
||||
assert real["id"] in tree["scoped_session_ids"]
|
||||
|
||||
|
||||
|
|
@ -462,8 +481,8 @@ def test_broad_default_non_git_cwd_stays_unscoped():
|
|||
is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"},
|
||||
)
|
||||
|
||||
assert tree["projects"] == []
|
||||
assert detached["id"] not in tree["scoped_session_ids"]
|
||||
assert _real_project_ids(tree) == []
|
||||
assert _home_session_ids(tree) == [detached["id"]]
|
||||
|
||||
|
||||
def test_deleted_sibling_worktree_folds_into_parent_home_checkout():
|
||||
|
|
@ -509,18 +528,19 @@ def test_deleted_unrelated_workspace_does_not_become_a_project():
|
|||
# A deleted dir the sibling probe can't reach by name (`hermes-salvage-drafts`
|
||||
# shares no prefix with `hermes-agent`; `/tmp/scratch` was never a worktree)
|
||||
# must not be promoted to a phantom project — it can never be opened and can
|
||||
# only be dismissed by hand. The session stays in flat Recents.
|
||||
# only be dismissed by hand. Those sessions land in the Home bucket.
|
||||
resolve = _resolver({"/www/hermes-agent": ("/www/hermes-agent", "/www/hermes-agent")})
|
||||
sessions = [
|
||||
live, salvage, scratch = (
|
||||
_session("/www/hermes-agent", branch="main"),
|
||||
_session("/www/hermes-salvage-drafts/apps/desktop"),
|
||||
_session("/tmp/scratch"),
|
||||
]
|
||||
)
|
||||
on_disk = {"/www/hermes-agent"}
|
||||
|
||||
tree = pt.build_tree([], sessions, [], resolve, hydrate=True, exists=lambda p: p in on_disk)
|
||||
tree = pt.build_tree([], [live, salvage, scratch], [], resolve, hydrate=True, exists=lambda p: p in on_disk)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == ["/www/hermes-agent"]
|
||||
assert _real_project_ids(tree) == ["/www/hermes-agent"]
|
||||
assert set(_home_session_ids(tree)) == {salvage["id"], scratch["id"]}
|
||||
|
||||
|
||||
def test_existing_non_git_workspace_still_becomes_a_project():
|
||||
|
|
@ -536,11 +556,12 @@ def test_existing_non_git_workspace_still_becomes_a_project():
|
|||
def test_stale_persisted_repo_root_does_not_become_a_project():
|
||||
# A session carrying a git_repo_root whose repo has since been deleted must
|
||||
# not resurrect it as a project on the strength of the persisted value alone.
|
||||
sessions = [_session("/tmp/gone/sub", repo_root="/tmp/gone")]
|
||||
stale = _session("/tmp/gone/sub", repo_root="/tmp/gone")
|
||||
|
||||
tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True, exists=lambda _p: False)
|
||||
tree = pt.build_tree([], [stale], [], lambda _cwd: None, hydrate=True, exists=lambda _p: False)
|
||||
|
||||
assert tree["projects"] == []
|
||||
assert _real_project_ids(tree) == []
|
||||
assert _home_session_ids(tree) == [stale["id"]]
|
||||
|
||||
|
||||
def test_exists_defaults_to_keeping_everything():
|
||||
|
|
@ -566,6 +587,55 @@ def test_sibling_probe_is_bounded():
|
|||
assert len(probed) <= pt._MAX_SIBLING_PROBES
|
||||
|
||||
|
||||
def test_home_bucket_leads_the_tree_and_is_lossless():
|
||||
# Every session a project didn't claim belongs to Home, and Home leads the
|
||||
# list — so the grouped view shows the same set of sessions as flat Recents.
|
||||
resolve = _resolver({"/www/app": ("/www/app", "/www/app"), "/home/me": ("/home/me", "/home/me")})
|
||||
owned = _session("/www/app", branch="main")
|
||||
cwdless = _session(None)
|
||||
junked = _session("/home/me", branch="main")
|
||||
|
||||
tree = pt.build_tree(
|
||||
[],
|
||||
[owned, cwdless, junked],
|
||||
[],
|
||||
resolve,
|
||||
hydrate=True,
|
||||
is_junk_root=lambda root: root == "/home/me",
|
||||
)
|
||||
|
||||
assert tree["projects"][0]["id"] == pt.NO_PROJECT_ID
|
||||
assert set(_home_session_ids(tree)) == {cwdless["id"], junked["id"]}
|
||||
assert {s["id"] for p in tree["projects"] for s in _sessions_of(p)} == {
|
||||
owned["id"],
|
||||
cwdless["id"],
|
||||
junked["id"],
|
||||
}
|
||||
|
||||
|
||||
def test_home_bucket_is_absent_when_every_session_is_placed():
|
||||
# No leftovers, no Home row — a fully-organized sidebar shows only projects.
|
||||
resolve = _resolver({"/www/app": ("/www/app", "/www/app")})
|
||||
|
||||
tree = pt.build_tree([], [_session("/www/app", branch="main")], [], resolve, hydrate=True)
|
||||
|
||||
assert _home(tree) is None
|
||||
|
||||
|
||||
def test_home_bucket_carries_previews_and_drops_rows_in_overview_mode():
|
||||
sessions = [_session(None, started_at=t, last_active=t) for t in (10, 30, 20, 40)]
|
||||
|
||||
tree = pt.build_tree([], sessions, [], resolve=None, preview_limit=3, hydrate=False)
|
||||
home = _home(tree)
|
||||
|
||||
assert home["sessionCount"] == 4
|
||||
assert home["lastActive"] == 40
|
||||
assert [s["last_active"] for s in home["previewSessions"]] == [40, 30, 20]
|
||||
assert _sessions_of(home) == []
|
||||
assert home["isNoProject"] is True
|
||||
assert home["path"] is None
|
||||
|
||||
|
||||
def test_colliding_repo_basenames_disambiguate_labels():
|
||||
resolve = _resolver(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ all key off these exact strings:
|
|||
|
||||
- explicit project id .......... ``p_<hex>`` (from projects.db)
|
||||
- auto/discovered project id ... the repo root path
|
||||
- home (no-project) bucket ..... ``__no_project__``
|
||||
- repo node id ................. the repo root path
|
||||
- main branch lane id .......... ``<repoRoot>::branch::<branch>`` (or ``::branch::``)
|
||||
- kanban bucket lane id ........ ``<repoRoot>::kanban``
|
||||
|
|
@ -48,6 +49,14 @@ _KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
|
|||
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
|
||||
DEFAULT_BRANCH_LABEL = "main"
|
||||
|
||||
# The synthetic bucket holding every session no project claimed — a chat with no
|
||||
# cwd at all, or one whose folder can't be promoted (the bare home dir, HERMES
|
||||
# state, a workspace that has since been deleted). Without it those sessions are
|
||||
# invisible in the grouped view. The desktop labels it "Home"; the id/flag stay
|
||||
# named for what the bucket MEANS, since that's what membership keys off.
|
||||
NO_PROJECT_ID = "__no_project__"
|
||||
NO_PROJECT_LABEL = "Home"
|
||||
|
||||
# How many sibling candidates to try when recovering a deleted worktree's parent
|
||||
# repo (see ``_probe_sibling_worktree``). Each miss costs a git probe, so keep it
|
||||
# tight — real suffixes are one or two segments.
|
||||
|
|
@ -508,6 +517,7 @@ def _project_node(
|
|||
color: Any = None,
|
||||
icon: Any = None,
|
||||
is_auto: bool = False,
|
||||
is_no_project: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": pid,
|
||||
|
|
@ -516,6 +526,7 @@ def _project_node(
|
|||
"color": color,
|
||||
"icon": icon,
|
||||
"isAuto": is_auto,
|
||||
"isNoProject": is_no_project,
|
||||
"sessionCount": session_count,
|
||||
"lastActive": last_active,
|
||||
"repos": repos,
|
||||
|
|
@ -609,10 +620,13 @@ def build_tree(
|
|||
# The pre-Projects desktop grouped every non-empty cwd; keeping that fallback
|
||||
# prevents upgrades from flattening those sessions into Recents.
|
||||
by_auto_root: dict[str, dict] = {}
|
||||
# Every session no tier could place. These are the Home bucket's rows.
|
||||
homeless: list[dict] = []
|
||||
|
||||
def _add_auto(root: str, session: dict) -> None:
|
||||
key = _path_key(root)
|
||||
if not key:
|
||||
homeless.append(session)
|
||||
return
|
||||
bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []})
|
||||
bucket["sessions"].append(session)
|
||||
|
|
@ -626,10 +640,13 @@ def build_tree(
|
|||
# session ran) and must not resurrect as a project.
|
||||
if not _junk(root) and _exists(root):
|
||||
_add_auto(root, session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
continue
|
||||
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd or _junk_cwd(cwd):
|
||||
homeless.append(session)
|
||||
continue
|
||||
placement = _place(
|
||||
cwd,
|
||||
|
|
@ -642,9 +659,11 @@ def build_tree(
|
|||
# also gone from disk (a deleted worktree whose name shares no prefix
|
||||
# with its parent, a removed /tmp scratch dir), promoting it mints a
|
||||
# phantom project that can never be opened and can only be dismissed by
|
||||
# hand. The session keeps its place in flat Recents.
|
||||
# hand. The session goes to Home instead.
|
||||
if placement and _exists(placement["repo_key"]):
|
||||
_add_auto(placement["repo_key"], session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
|
||||
seen: set[str] = set()
|
||||
for bucket in by_auto_root.values():
|
||||
|
|
@ -661,6 +680,7 @@ def build_tree(
|
|||
None,
|
||||
)
|
||||
if repo_node is None:
|
||||
homeless.extend(auto_sessions)
|
||||
continue
|
||||
seen.add(auto_key)
|
||||
scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id"))
|
||||
|
|
@ -708,4 +728,41 @@ def build_tree(
|
|||
# Explicit projects keep their user-chosen names untouched.
|
||||
_disambiguate_labels([p for p in result if p.get("isAuto")])
|
||||
|
||||
# Tier 0: everything the tiers above could not place, so the grouped view
|
||||
# loses no session. It has no folder, hence no repo/lane structure — the one
|
||||
# synthetic lane exists purely to carry the rows in the tree's shape. Leads
|
||||
# the list; omitted entirely when empty, so a project-less install is blank.
|
||||
if homeless:
|
||||
homeless.sort(key=_session_time, reverse=True)
|
||||
scoped_ids.extend(s["id"] for s in homeless if s.get("id"))
|
||||
lane = {
|
||||
"id": NO_PROJECT_ID,
|
||||
"label": NO_PROJECT_LABEL,
|
||||
"path": None,
|
||||
"isMain": False,
|
||||
"isKanban": False,
|
||||
"sessions": homeless if hydrate else [],
|
||||
}
|
||||
result.insert(
|
||||
0,
|
||||
_project_node(
|
||||
pid=NO_PROJECT_ID,
|
||||
label=NO_PROJECT_LABEL,
|
||||
path=None,
|
||||
repos=[
|
||||
{
|
||||
"id": NO_PROJECT_ID,
|
||||
"label": NO_PROJECT_LABEL,
|
||||
"path": None,
|
||||
"groups": [lane],
|
||||
"sessionCount": len(homeless),
|
||||
}
|
||||
],
|
||||
session_count=len(homeless),
|
||||
last_active=_last_active(homeless),
|
||||
preview_sessions=_previews(homeless),
|
||||
is_no_project=True,
|
||||
),
|
||||
)
|
||||
|
||||
return {"projects": result, "scoped_session_ids": scoped_ids}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue