From 28b3b0dd1c7bd42b22895b2594f19f0e8c860b2e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 13:23:00 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(gateway):=20session.workspace.move=20?= =?UTF-8?q?=E2=80=94=20re-home=20a=20stored=20session's=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session created in the wrong directory needs its cwd corrected after the fact. session.cwd.set only reaches live runtime sessions, so cold rows were stuck. The new RPC targets the persisted row by session_key, validates the folder, and REPLACES the git branch/root identity (update_session_cwd grows a replace_git_meta flag) so the project tree's grouping follows the move instead of pinning the session under the project it left via a stale git_repo_root. A live idle agent bound to the row is re-anchored through the runtime path; a mid-turn session refuses with 'session busy'. Runs on the RPC pool — the git probes are subprocesses. --- hermes_state.py | 20 ++++++--- tui_gateway/methods_session.py | 74 ++++++++++++++++++++++++++++++++++ tui_gateway/server.py | 3 ++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 9b8215390d1e6..7b8d8bfd907fd 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3649,7 +3649,12 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) return False def update_session_cwd( - self, session_id: str, cwd: str, git_branch: str = None, git_repo_root: str = None + self, + session_id: str, + cwd: str, + git_branch: str = None, + git_repo_root: str = None, + replace_git_meta: bool = False, ) -> None: """Persist the session working directory when a frontend knows it. @@ -3664,6 +3669,11 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) every surface reads the same membership instead of re-probing git in the GUI over a partial page. Each field is only written when non-empty so a probe failure never clobbers a previously-captured value. + + ``replace_git_meta`` inverts that non-empty rule: a deliberate workspace + MOVE (re-homing a session into another project) must overwrite the old + repo identity even when the new cwd resolves to none — keeping the stale + root would leave the session grouped under the project it just left. """ if not session_id or not cwd: return @@ -3673,12 +3683,12 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) sets = ["cwd = ?"] params: List[Any] = [cwd] - if branch: + if branch or replace_git_meta: sets.append("git_branch = ?") - params.append(branch) - if repo_root: + params.append(branch or None) + if repo_root or replace_git_meta: sets.append("git_repo_root = ?") - params.append(repo_root) + params.append(repo_root or None) params.append(session_id) def _do(conn): diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 1a3e09f16e49c..bdbcd1cfafafe 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -747,6 +747,80 @@ def _(rid, params: dict) -> dict: return _ok(rid, info) +@method("session.workspace.move") +def _(rid, params: dict) -> dict: + """Re-home a STORED session's workspace into another folder/project. + + Unlike ``session.cwd.set`` (which acts on a live runtime session by its UI + id), this targets a persisted row by ``session_key`` so the desktop can fix + a session that was created in the wrong directory — no live agent required. + The git branch/root columns are REPLACED (not merely enriched), because the + whole point of the move is to change which project claims the session; a + stale ``git_repo_root`` would keep it grouped under the project it left. + + A live agent bound to the row follows through the runtime path too, so its + terminal/file tools re-anchor immediately; a mid-turn session refuses the + move rather than yanking the workspace out from under its tools. + """ + target = str(params.get("session_key") or "").strip() + if not target: + return _err(rid, 4007, "session_key required") + raw = str(params.get("cwd", "") or "").strip() + if not raw: + return _err(rid, 4016, "cwd required") + from hermes_constants import translate_cwd_for_wsl_backend + + resolved = os.path.abspath(os.path.expanduser(translate_cwd_for_wsl_backend(raw))) + if not os.path.isdir(resolved): + return _err(rid, 4017, f"working directory does not exist: {raw}") + + # Snapshot under the lock — concurrent RPCs mutate _sessions (same pattern + # as _cwd_for_session_key). + live = None + live_sid = "" + with _sessions_lock: + for sid, sess in list(_sessions.items()): + if sess.get("session_key") == target: + live, live_sid = sess, sid + break + if live is not None and live.get("running"): + return _err(rid, 4009, "session busy") + + branch = _git_branch_for_cwd(resolved) + root = _git_common_repo_root_for_cwd(resolved) + with _profile_db(params) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + # A brand-new draft has no persisted row yet; the live re-home below + # still applies and the row inherits the cwd when it is first written. + row_exists = bool(db.get_session(target)) + if not row_exists and live is None: + return _err(rid, 4007, "session not found") + if row_exists: + try: + db.update_session_cwd( + target, resolved, branch, root, replace_git_meta=True + ) + except Exception as e: + return _err(rid, 5007, f"move failed: {e}") + + if live is not None: + try: + _set_session_cwd(live, resolved) + except ValueError as e: + return _err(rid, 4017, str(e)) + agent = live.get("agent") + info = _session_info(agent, live) if agent is not None else { + "cwd": resolved, + "branch": branch, + "project": _project_info_for_cwd(resolved), + "lazy": True, + } + _emit("session.info", live_sid, info) + + return _ok(rid, {"cwd": resolved, "branch": branch, "git_repo_root": root}) + + @method("session.active_list") def _(rid, params: dict) -> dict: """Return live TUI sessions in this gateway process. diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9d5fd00ce7d0b..a36a539408b1f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -274,6 +274,9 @@ _LONG_HANDLERS = frozenset( "session.compress", "session.list", "session.resume", + # Workspace re-home runs git branch/root subprocess probes against an + # arbitrary folder — inline they'd stall the reader on a slow mount. + "session.workspace.move", "shell.exec", "skills.manage", "slash.exec", From edae3eed10dcdc1ba498b8dae95315e88265d9c0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 13:23:00 -0600 Subject: [PATCH 2/2] feat(desktop): move a session to another project from its row menu 'Move to project' submenu in the session actions menu (kebab and right-click, via the shared MenuKit) listing every project with a folder except the current owner. Picking one calls session.workspace.move at the project root, mirrors the new cwd/branch/root into the $sessions cache, and refreshes the tree so the row hops immediately. --- .../sidebar/session-actions-menu.test.tsx | 15 +++++- .../app/chat/sidebar/session-actions-menu.tsx | 48 +++++++++++++++++++ apps/desktop/src/i18n/en.ts | 5 ++ apps/desktop/src/i18n/types.ts | 5 ++ apps/desktop/src/i18n/zh.ts | 5 ++ apps/desktop/src/store/projects.ts | 42 +++++++++++++++- 6 files changed, 118 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx index d3e197958f182..559d648407bce 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx @@ -22,7 +22,14 @@ vi.mock('@/i18n', () => ({ t: { common: { cancel: 'Cancel', close: 'Close', delete: 'Delete', save: 'Save' }, sidebar: { - projects: { menuAppearance: 'Appearance', noColor: 'No color' }, + projects: { + menuAppearance: 'Appearance', + moveFailed: 'Could not move session', + moveNoProjects: 'No other projects', + movedTo: (name: string) => `Moved to ${name}`, + moveToProject: 'Move to project', + noColor: 'No color' + }, row: { archive: 'Archive', branchFrom: 'Branch from here', @@ -50,6 +57,12 @@ vi.mock('@/lib/profile-color', () => ({ PROFILE_SWATCHES: [] })) vi.mock('@/lib/session-export', () => ({ exportSession: vi.fn() })) vi.mock('@/store/gateway', () => ({ activeGateway: vi.fn(() => null) })) vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) +vi.mock('@/store/projects', () => ({ + $projectTree: atom([]), + moveSessionToProject: vi.fn(), + projectIdForCwd: vi.fn(() => null), + projectRootCwd: vi.fn(() => '') +})) vi.mock('@/store/session', () => ({ $activeSessionId: atom(null), $selectedStoredSessionId: atom(null), diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 57d8a9f9623f1..0a32e1881f85b 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -30,6 +30,7 @@ import { PROFILE_SWATCHES } from '@/lib/profile-color' import { exportSession } from '@/lib/session-export' import { activeGateway } from '@/store/gateway' import { notify, notifyError } from '@/store/notifications' +import { $projectTree, moveSessionToProject, projectIdForCwd, projectRootCwd } from '@/store/projects' import { $activeSessionId, $selectedStoredSessionId, @@ -133,6 +134,44 @@ function SessionColorSwatches({ sessionId }: { sessionId: string }) { ) } +// The project list inside the session menu's "Move to project" submenu. Its own +// component so only an OPEN submenu subscribes to the stores (same reasoning as +// SessionColorSwatches). Re-homes the session's workspace at the target +// project's root — the fix for a chat created in the wrong folder. The current +// owner and folderless projects (the Home bucket) are excluded: there is +// nothing to move into. +function MoveToProjectItems({ kit, sessionId, profile }: { kit: MenuKit; sessionId: string; profile?: string }) { + const { t } = useI18n() + const p = t.sidebar.projects + const tree = useStore($projectTree) + const session = useStore($sessions).find(s => sessionMatchesStoredId(s, sessionId)) + const cwd = session?.cwd?.trim() || '' + const currentProjectId = cwd ? projectIdForCwd(cwd) : null + const targets = tree.filter(node => node.id !== currentProjectId && !node.isNoProject && projectRootCwd(node)) + + if (targets.length === 0) { + return {p.moveNoProjects} + } + + return ( + <> + {targets.map(node => ( + { + triggerHaptic('selection') + moveSessionToProject(sessionId, node.id, profile) + .then(() => notify({ durationMs: 2_000, kind: 'success', message: p.movedTo(node.label) })) + .catch(err => notifyError(err, p.moveFailed)) + }} + > + {node.label} + + ))} + + ) +} + function useSessionActions({ sessionId, title, @@ -355,6 +394,15 @@ function useSessionActions({ /> {workItems.map(item => renderActionItem(kit, item))} + + + + {t.sidebar.projects.moveToProject} + + + + + {tabItems.length > 0 && ( <> diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 2ec60d2859a19..a79e4d5b29307 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1876,6 +1876,11 @@ export const en: Translations = { menuAddFolder: 'Add folder', menuSetActive: 'Set active', menuDelete: 'Delete', + moveToProject: 'Move to project', + movedTo: name => `Moved to ${name}`, + moveFailed: 'Could not move session', + moveNoFolder: 'That project has no folder to move into', + moveNoProjects: 'No other projects', reveal: 'Reveal in folder', copyPath: 'Copy path', removeFromSidebar: 'Hide from sidebar', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index e2a49f4234dc1..9b0204c054ec8 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1573,6 +1573,11 @@ export interface Translations { menuAddFolder: string menuSetActive: string menuDelete: string + moveToProject: string + movedTo: (name: string) => string + moveFailed: string + moveNoFolder: string + moveNoProjects: string reveal: string copyPath: string removeFromSidebar: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 79a9a1c143eb2..c176ed0d91209 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2070,6 +2070,11 @@ export const zh: Translations = { menuAddFolder: '添加文件夹', menuSetActive: '设为活动', menuDelete: '删除', + moveToProject: '移动到项目', + movedTo: name => `已移动到 ${name}`, + moveFailed: '无法移动会话', + moveNoFolder: '该项目没有可移入的文件夹', + moveNoProjects: '没有其他项目', reveal: '在文件夹中显示', copyPath: '复制路径', removeFromSidebar: '从侧边栏移除', diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 2ba0d7e59bbc9..6bb3451a88ba7 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -22,6 +22,7 @@ import { $sessions, idsShareLineage, sessionMatchesStoredId, + setSessions, workspaceCwdForNewSession } from '@/store/session' import { $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states' @@ -173,7 +174,7 @@ export function exitProjectScope(): void { // one. Empty for the path-less Home bucket. (The sidebar's `projectTreeCwd` is // the same rule over the same tree — this is the store-side copy so the store // doesn't reach into the sidebar's React module.) -const projectRootCwd = (project: SidebarProjectTree | undefined): string => +export const projectRootCwd = (project: SidebarProjectTree | undefined): string => (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim() // ⌘K "go to project": flip the sidebar into grouped mode and enter the project @@ -520,6 +521,45 @@ export async function fetchProjectSessions(projectId: string): Promise { + const cwd = projectRootCwd($projectTree.get().find(node => node.id === projectId)) + + if (!cwd) { + throw new Error(translateNow('sidebar.projects.moveNoFolder')) + } + + const res = await gatewayRequest('session.workspace.move', { + cwd, + session_key: sessionId, + ...(profile ? { profile } : {}) + }) + + const moved = res.cwd || cwd + setSessions(prev => + prev.map(s => + sessionMatchesStoredId(s, sessionId) + ? { ...s, cwd: moved, git_branch: res.branch ?? null, git_repo_root: res.git_repo_root ?? null } + : s + ) + ) + void refreshProjectTree() +} + export interface RepoDiscoveryPolicy { enabled: boolean roots: string[]