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:
commit
ec9572f876
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2070,6 +2070,11 @@ export const zh: Translations = {
|
|||
menuAddFolder: '添加文件夹',
|
||||
menuSetActive: '设为活动',
|
||||
menuDelete: '删除',
|
||||
moveToProject: '移动到项目',
|
||||
movedTo: name => `已移动到 ${name}`,
|
||||
moveFailed: '无法移动会话',
|
||||
moveNoFolder: '该项目没有可移入的文件夹',
|
||||
moveNoProjects: '没有其他项目',
|
||||
reveal: '在文件夹中显示',
|
||||
copyPath: '复制路径',
|
||||
removeFromSidebar: '从侧边栏移除',
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue