Merge pull request #78854 from NousResearch/bb/session-move-project

Right-click a session to move it into another project
This commit is contained in:
brooklyn!
2026-08-04 13:35:09 -06:00
committed by GitHub
9 changed files with 210 additions and 7 deletions
@@ -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<unknown[]>([]),
moveSessionToProject: vi.fn(),
projectIdForCwd: vi.fn(() => null),
projectRootCwd: vi.fn(() => '')
}))
vi.mock('@/store/session', () => ({
$activeSessionId: atom<null | string>(null),
$selectedStoredSessionId: atom<null | string>(null),
@@ -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 <kit.Item disabled>{p.moveNoProjects}</kit.Item>
}
return (
<>
{targets.map(node => (
<kit.Item
key={node.id}
onSelect={() => {
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}
</kit.Item>
))}
</>
)
}
function useSessionActions({
sessionId,
title,
@@ -355,6 +394,15 @@ function useSessionActions({
/>
<kit.Separator />
{workItems.map(item => renderActionItem(kit, item))}
<kit.Sub>
<kit.SubTrigger disabled={!sessionId}>
<Codicon name="folder" size="0.875rem" />
<span>{t.sidebar.projects.moveToProject}</span>
</kit.SubTrigger>
<kit.SubContent>
<MoveToProjectItems kit={kit} profile={profile} sessionId={sessionId} />
</kit.SubContent>
</kit.Sub>
{tabItems.length > 0 && (
<>
<kit.Separator />
+5
View File
@@ -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',
+5
View File
@@ -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
+5
View File
@@ -2070,6 +2070,11 @@ export const zh: Translations = {
menuAddFolder: '添加文件夹',
menuSetActive: '设为活动',
menuDelete: '删除',
moveToProject: '移动到项目',
movedTo: name => `已移动到 ${name}`,
moveFailed: '无法移动会话',
moveNoFolder: '该项目没有可移入的文件夹',
moveNoProjects: '没有其他项目',
reveal: '在文件夹中显示',
copyPath: '复制路径',
removeFromSidebar: '从侧边栏移除',
+41 -1
View File
@@ -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<SidebarPr
}
}
interface WorkspaceMovePayload {
branch?: null | string
cwd?: string
git_repo_root?: null | string
}
// Re-home a stored session into another project's root folder — the fix for a
// chat created in the wrong directory. The backend replaces cwd + git identity
// (so the tree's grouping follows) and re-anchors any live agent bound to the
// row; here we mirror the move into the `$sessions` cache so both the flat list
// and the grouped tree reflect it before the next authoritative refresh.
export async function moveSessionToProject(
sessionId: string,
projectId: string,
profile?: null | string
): Promise<void> {
const cwd = projectRootCwd($projectTree.get().find(node => node.id === projectId))
if (!cwd) {
throw new Error(translateNow('sidebar.projects.moveNoFolder'))
}
const res = await gatewayRequest<WorkspaceMovePayload>('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[]
+15 -5
View File
@@ -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):
+74
View File
@@ -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.
+3
View File
@@ -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",