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 92beaf6ae35d8..880b3cfc63713 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 b691291172dae..c9a106a87149d 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 18d0c45287f77..d512af10b061c 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[] diff --git a/hermes_state.py b/hermes_state.py index 38b90ed7e4fd3..76f4ee2c33581 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",