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
commit ec9572f876
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 210 additions and 7 deletions

View File

@ -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),

View File

@ -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 />

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',

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

View File

@ -2070,6 +2070,11 @@ export const zh: Translations = {
menuAddFolder: '添加文件夹',
menuSetActive: '设为活动',
menuDelete: '删除',
moveToProject: '移动到项目',
movedTo: name => `已移动到 ${name}`,
moveFailed: '无法移动会话',
moveNoFolder: '该项目没有可移入的文件夹',
moveNoProjects: '没有其他项目',
reveal: '在文件夹中显示',
copyPath: '复制路径',
removeFromSidebar: '从侧边栏移除',

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[]

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):

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.

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",