diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index 1acb712dff2c..067a47586388 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -295,6 +295,30 @@ def test_scan_time_is_not_treated_as_session_activity(tmp_path): 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() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7375ab542cf4..6cffc43e48ee 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2086,6 +2086,25 @@ 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 + return _session_cwd(session) or None + + def _heal_dead_cwd(cwd: str) -> str: """Resolve a session cwd that points at a now-deleted directory. @@ -2184,12 +2203,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 +2304,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).