From 60b6ea237f6d48f31e2e5f50dcbfaac440772a31 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 16:04:57 -0500 Subject: [PATCH 1/3] feat(gateway): group unplaced sessions into a Home bucket in the project tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/tui_gateway/test_project_tree.py | 106 ++++++++++++++++++++----- tui_gateway/project_tree.py | 59 +++++++++++++- 2 files changed, 146 insertions(+), 19 deletions(-) diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index a7e73703f94..f02b596ebcd 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -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( { diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index 545600eaf6c..413f8502386 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -12,6 +12,7 @@ all key off these exact strings: - explicit project id .......... ``p_`` (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 .......... ``::branch::`` (or ``::branch::``) - kanban bucket lane id ........ ``::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} From ef0f4763e366c3d62a50aba2717eba910b858253 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 16:05:10 -0500 Subject: [PATCH 2/3] i18n(desktop): name the project-less bucket "Home" The sidebar row is a place you enter, not a status line, so it reads as a destination rather than "No project". --- apps/desktop/src/i18n/ar.ts | 2 +- apps/desktop/src/i18n/en.ts | 2 +- apps/desktop/src/i18n/ja.ts | 2 +- apps/desktop/src/i18n/types.ts | 2 +- apps/desktop/src/i18n/zh-hant.ts | 2 +- apps/desktop/src/i18n/zh.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index cb4a8d74fc0..e8eb0f93c43 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1546,11 +1546,11 @@ export const ar = defineLocale({ allPinned: 'كل الجلسات مثبتة', shiftClickHint: 'استخدم Shift للتحديد المتعدد', noWorkspace: 'بدون مساحة عمل', - noProject: 'لا يوجد مشروع', projectEmpty: 'لا توجد جلسات بعد', noSessions: 'لا توجد جلسات بعد', projects: { sectionLabel: 'المشاريع', + home: 'الرئيسية', newButton: 'مشروع جديد', createTitle: 'مشروع جديد', createDesc: 'سمِّ مساحة العمل وأضف مجلدا أو أكثر.', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index abdd8c4c402..962116d280f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1804,11 +1804,11 @@ export const en: Translations = { allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.', shiftClickHint: 'Shift-click a chat to pin', noWorkspace: 'No workspace', - noProject: 'No project', projectEmpty: 'No sessions yet', noSessions: 'No sessions yet', projects: { sectionLabel: 'Projects', + home: 'Home', newButton: 'New project', createTitle: 'New project', createDesc: 'Name a workspace and add one or more folders.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 198058eedd6..442b13b3eb7 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1668,11 +1668,11 @@ export const ja = defineLocale({ allPinned: 'ここにあるものはすべてピン留めされています。チャットのピン留めを解除すると最近のものに表示されます。', shiftClickHint: 'Shift クリックでピン留め · ドラッグで並べ替え', noWorkspace: 'ワークスペースなし', - noProject: 'プロジェクトなし', projectEmpty: 'セッションはまだありません', noSessions: 'セッションはまだありません', projects: { sectionLabel: 'プロジェクト', + home: 'ホーム', newButton: '新規プロジェクト', createTitle: '新規プロジェクト', createDesc: 'ワークスペースに名前を付け、1つ以上のフォルダを追加します。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index aacf8641e25..8187517b061 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1508,11 +1508,11 @@ export interface Translations { allPinned: string shiftClickHint: string noWorkspace: string - noProject: string projectEmpty: string noSessions: string projects: { sectionLabel: string + home: string newButton: string createTitle: string createDesc: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 12e7a5c2c59..6bcaabccfbd 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1616,11 +1616,11 @@ export const zhHant = defineLocale({ allPinned: '這裡的全部已釘選。取消釘選某個聊天即可在最近中顯示。', shiftClickHint: 'Shift + 點擊聊天以釘選 · 拖曳以重新排序', noWorkspace: '無工作區', - noProject: '無專案', projectEmpty: '尚無工作階段', noSessions: '尚無工作階段', projects: { sectionLabel: '專案', + home: '主頁', newButton: '新增專案', createTitle: '新增專案', createDesc: '為工作區命名並新增一個或多個資料夾。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index ad3e22e60b7..bc46aa6aaf4 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1994,11 +1994,11 @@ export const zh: Translations = { allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。', shiftClickHint: 'Shift+ 单击对话以置顶 · 拖动以重新排序', noWorkspace: '无工作区', - noProject: '无项目', projectEmpty: '暂无会话', noSessions: '暂无会话', projects: { sectionLabel: '项目', + home: '主页', newButton: '新建项目', createTitle: '新建项目', createDesc: '为工作区命名并添加一个或多个文件夹。', From 5a5d7b938615e274bbdb631dd2b57d1d4b7a7b53 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 16:05:19 -0500 Subject: [PATCH 3/3] feat(desktop): pin Home to the top of the project sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home leads the overview above the active project and outside any drag order, drills in to a flat chat list (it has no repo or worktree structure), and overlays live sessions so a brand-new detached chat appears instantly. Starting a chat from inside Home stays detached instead of picking up the configured default project dir. Rename and delete are hidden — there's no record behind the row. --- apps/desktop/src/app/chat/sidebar/index.tsx | 31 ++++++---- .../chat/sidebar/projects/entered-content.tsx | 6 ++ .../app/chat/sidebar/projects/model.test.ts | 26 +++++++- .../src/app/chat/sidebar/projects/model.ts | 17 ++++-- .../sidebar/projects/overview-row.test.tsx | 8 +++ .../chat/sidebar/projects/overview-row.tsx | 10 ++-- .../sidebar/projects/workspace-groups.test.ts | 46 +++++++++++++++ .../chat/sidebar/projects/workspace-groups.ts | 59 ++++++++++++++++--- .../src/app/chat/sidebar/sessions-section.tsx | 51 +++++++++------- apps/desktop/src/store/projects.test.ts | 36 +++++++++-- apps/desktop/src/store/projects.ts | 20 +++++-- 11 files changed, 251 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index c1f00e5777c..8652ade4e2f 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -615,7 +615,13 @@ export function ChatSidebar({ const sorted = sortProjectsForOverview( projectTree .filter(node => !(node.isAuto && dismissed.has(node.id))) - .map(project => ({ ...project, repos: orderRepos(project.repos) })), + .map(project => ({ + ...project, + // Home is synthetic, so its name is ours to translate — every other + // label is a repo basename or a name the user typed. + label: project.isNoProject ? s.projects.home : project.label, + repos: orderRepos(project.repos) + })), activeProjectId ) @@ -623,7 +629,7 @@ export function ChatSidebar({ // (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]) + }, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds, s]) // The overview only renders in grouped mode; the model stays live regardless // so scoping is consistent across views. @@ -685,7 +691,9 @@ export function ChatSidebar({ // The live-session overlay (creates/evictions) is applied per-repo in // RepoFlatSection, AFTER the visual git-worktree lanes are merged in (so // out-of-tree worktrees can be placed). Here we just order the snapshot. - return { ...hydrated, repos: orderRepos(hydrated.repos) } + // The label comes from the overview node either way — that's the model's + // presentation copy (Home is translated there), not the raw payload's. + return { ...hydrated, label: overviewEnteredProject.label, repos: orderRepos(hydrated.repos) } }, [overviewEnteredProject, enteredProjectTree, orderRepos]) // Overlay live `$sessions` onto the entered project so a just-created session @@ -785,7 +793,7 @@ export function ChatSidebar({ // Preview rows come from the backend tree (each project carries its // most-recent sessions), overlaid with live $sessions so a just-created // session shows under its project instantly (and with its working arc), - // matching the flat Recents list. Keyed by project path for the rows. + // matching the flat Recents list. Keyed by project id for the rows. const overviewPreviews = useMemo>( () => overlayLivePreviews(projectOverview ?? [], agentSessions, projects, PROJECT_PREVIEW_COUNT, removedSessionIds), [projectOverview, agentSessions, projects, removedSessionIds] @@ -1304,12 +1312,15 @@ export function ChatSidebar({ {enteredProject.path && ( )} - + {/* Home has no folder and no record to rename, theme, or delete. */} + {!enteredProject.isNoProject && ( + + )}