diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 7905e030639..f67c7d82a3a 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -210,6 +210,7 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeState = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeState({ + sessionFilePath: session.filePath, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -229,6 +230,7 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeActions = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ + sessionFilePath: session.filePath, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index fe2e07bc338..5ea6bd9d307 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -101,12 +101,13 @@ export function VaultSessionRow({ sessionId: session.sessionId, title: session.title, command: resumeStartup.command, + sessionFilePath: session.filePath, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT)) }, - [resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup] + [resumeDisabled, session, resumeStartup] ) return ( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index ac5c07645fa..dc2ed601f83 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -12,8 +12,8 @@ import { } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import type { Worktree } from '../../../../shared/types' @@ -78,6 +78,7 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTarget({ + sessionFilePath: session.filePath, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, targetWorktreeId, targetState @@ -93,17 +94,7 @@ export function useAiVaultSessionLaunchActions({ } if (targetId.status === 'unsupported') { - toast.error( - targetId.targetStatus === 'runtime' - ? translate( - 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', - 'Resume from history is not available in runtime-hosted workspaces.' - ) - : translate( - 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', - 'Open a local or SSH workspace before resuming a session.' - ) - ) + toast.error(aiVaultResumeUnsupportedMessage(targetId.targetStatus)) return } @@ -138,6 +129,7 @@ export type AiVaultSessionLaunchTarget = | { status: 'ready'; worktreeId: string } export function resolveAiVaultSessionLaunchTarget(args: { + sessionFilePath: string | null activeWorktreeId: string | null targetWorktreeId?: string targetState: AiVaultSessionResumeTargetState @@ -151,13 +143,36 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { return { status: 'unsupported', targetStatus } } return { status: 'ready', worktreeId: targetWorktreeId } } +function aiVaultResumeUnsupportedMessage( + targetStatus: ReturnType +): string { + if (targetStatus === 'runtime') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', + 'Resume from history is not available in runtime-hosted workspaces.' + ) + } + // Why: 'ssh' only reaches the unsupported branch when the session file lives + // on this machine, so the message explains the host mismatch, not SSH itself. + if (targetStatus === 'ssh') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported', + "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', + 'Open a local or SSH workspace before resuming a session.' + ) +} + function activateAiVaultResumeWorkspace(workspaceId: string): void { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index a3cf98dd7f5..342aed8b015 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -106,10 +106,14 @@ function makeWorktreeInfo( } } +const HOST_SESSION_FILE = '/Users/ada/.claude/projects/-repo-orca/session-1.jsonl' +const WSL_SESSION_FILE = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-repo-orca\\session-1.jsonl' + describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree over the active workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -128,6 +132,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is unavailable', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -143,6 +148,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks missing worktrees even when the repo is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [], @@ -158,6 +164,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-owned session worktrees', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -173,6 +180,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-stamped worktrees even when the repo owner is runtime', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'ssh:ssh-1' })], @@ -188,6 +196,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-owned targets even when they have no SSH connection', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -208,6 +217,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-stamped worktrees even when the repo owner is local', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'runtime:env-1' })], @@ -223,6 +233,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree when the active workspace is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -245,6 +256,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is runtime-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -275,6 +287,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to an active SSH folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -288,9 +301,27 @@ describe('resolveAiVaultSessionResumeState', () => { }) }) + it('blocks host-stored sessions when only an SSH workspace is open', () => { + expect( + resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('archived'), + activeWorktreeId: folderWorkspaceKey('folder-1'), + worktrees: [], + repos: [], + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + blocked: true, + worktreeId: null, + usesSessionWorktree: false + }) + }) + it('blocks an active runtime folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -309,6 +340,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('exposes separate session-worktree and active-workspace targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -326,6 +358,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables an SSH active-workspace action when the session worktree is local', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -344,9 +377,32 @@ describe('resolveAiVaultSessionResumeActions', () => { }) }) + it('disables the SSH active-workspace action for host-stored sessions', () => { + expect( + resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-2::/remote/orca', + worktrees: [ + makeWorktree(), + makeWorktree({ + id: 'repo-2::/remote/orca', + repoId: 'repo-2', + path: '/remote/orca' + }) + ], + repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo] + }) + ).toEqual({ + worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false }, + newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true } + }) + }) + it('disables runtime-owned targets without disabling local targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -376,6 +432,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('does not expose the active workspace as a duplicate new-tab target', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('current'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -390,6 +447,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables the active folder workspace action when it is local or SSH-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -405,6 +463,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('disables the active folder workspace action when it is runtime-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -422,6 +481,7 @@ describe('resolveAiVaultSessionLaunchTarget', () => { it('allows direct resume into an active SSH folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) }) @@ -431,9 +491,23 @@ describe('resolveAiVaultSessionLaunchTarget', () => { }) }) + it('blocks direct resume of a host-stored session into an SSH folder workspace', () => { + expect( + resolveAiVaultSessionLaunchTarget({ + sessionFilePath: HOST_SESSION_FILE, + activeWorktreeId: folderWorkspaceKey('folder-1'), + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + status: 'unsupported', + targetStatus: 'ssh' + }) + }) + it('blocks direct resume into a runtime folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 07091909d94..b8a428cd0c0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,7 +1,7 @@ import type { Repo, Worktree } from '../../../../shared/types' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' @@ -33,6 +33,7 @@ export type AiVaultSessionResumeActions = { } export function resolveAiVaultSessionResumeState(args: { + sessionFilePath: string | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -54,6 +55,7 @@ export function resolveAiVaultSessionResumeState(args: { for (const worktreeId of candidateWorktreeIds) { const targetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId, targetState }) @@ -75,6 +77,7 @@ export function resolveAiVaultSessionResumeState(args: { } export function resolveAiVaultSessionResumeActions(args: { + sessionFilePath: string | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -88,10 +91,12 @@ export function resolveAiVaultSessionResumeActions(args: { const targetState = resolveAiVaultResumeTargetState(args) const sessionTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -136,6 +141,7 @@ export function isKnownAiVaultResumeWorkspaceTarget( } function resolveSupportedResumeWorktreeId(args: { + sessionFilePath: string | null worktreeId: string | null targetState: AiVaultSessionResumeTargetState }): string | null { @@ -148,7 +154,7 @@ function resolveSupportedResumeWorktreeId(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { return null } diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index c0362c59c68..ee41d5c6e81 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { toast } from 'sonner' -import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' +import { + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus +} from '@/lib/ai-vault-resume-target' import { AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, @@ -185,6 +188,20 @@ export default function AiVaultSessionDropLayer({ ) return true } + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: payload.sessionFilePath ?? null, + targetStatus + }) + ) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported', + "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." + ) + ) + return true + } launchAiVaultSessionInNewTab({ agent: payload.agent, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b9113f064cd..5611d8d73ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "Open a local workspace before resuming a session.", "sessionQueued": "Session queued", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." }, "TabGroupDropOverlay": { "paneColumnLabel": "New split" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ea71f964f4b..ebca92c506e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.", "sessionQueued": "Sesión en cola", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión está almacenado en este equipo, por lo que no se puede reanudar en un espacio de trabajo SSH. En su lugar, suéltala en un espacio de trabajo local." }, "TabGroupDropOverlay": { "paneColumnLabel": "Nueva división" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión está almacenado en este equipo, por lo que no se puede reanudar en un espacio de trabajo SSH. En su lugar, abre un espacio de trabajo local." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 177d52f50b2..c02e6f709d8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。", "sessionQueued": "セッションをキューに追加しました", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新しい分割" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "ワークツリーは利用できなくなりました。", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ab7493ba20b..ce2b4283fcc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.", "sessionQueued": "세션이 대기열에 추가됨", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요." }, "TabGroupDropOverlay": { "paneColumnLabel": "새 분할" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 78f552eef4a..719e1e9856b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "恢复会话前请先打开一个本地工作区。", "sessionQueued": "会话已排队", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新建拆分" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index 2bbd2f0bb32..a2c827cf99b 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { + canResumeAiVaultSessionOnTarget, getAiVaultResumeRepoTargetStatus, getAiVaultResumeWorktreeTargetStatus, getAiVaultResumeWorkspaceTargetStatus, isSupportedAiVaultResumeRepo, - isUnsupportedAiVaultResumeRepo + isUnsupportedAiVaultResumeRepo, + isWslStoredAiVaultSessionFile } from './ai-vault-resume-target' import { folderWorkspaceKey } from '../../../shared/workspace-scope' @@ -26,6 +28,56 @@ function makeState( } as unknown as ResumeTargetState } +describe('ai vault session storage compatibility', () => { + const hostSessionFile = '/Users/ada/.claude/projects/-Users-ada-repo/session-1.jsonl' + const windowsHostSessionFile = 'C:\\Users\\ada\\.claude\\projects\\C--repo\\session-1.jsonl' + const wslSessionFile = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl' + const wslLocalhostSessionFile = + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl' + + it('detects WSL-stored session files', () => { + expect(isWslStoredAiVaultSessionFile(wslSessionFile)).toBe(true) + expect(isWslStoredAiVaultSessionFile(wslLocalhostSessionFile)).toBe(true) + expect(isWslStoredAiVaultSessionFile(hostSessionFile)).toBe(false) + expect(isWslStoredAiVaultSessionFile(windowsHostSessionFile)).toBe(false) + expect(isWslStoredAiVaultSessionFile(null)).toBe(false) + expect(isWslStoredAiVaultSessionFile(undefined)).toBe(false) + }) + + it('allows host-stored sessions on local targets only', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'local' }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'ssh' }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: windowsHostSessionFile, + targetStatus: 'ssh' + }) + ).toBe(false) + }) + + it('allows WSL-stored sessions on local and SSH targets', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'local' }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'ssh' }) + ).toBe(true) + }) + + it('never allows runtime or unknown targets', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' }) + ).toBe(false) + }) +}) + describe('ai vault resume target ownership', () => { it('classifies local, SSH, runtime, and unknown repo owners', () => { expect(getAiVaultResumeRepoTargetStatus({ connectionId: null, executionHostId: 'local' })).toBe( diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 0e2afff8f34..d4fae026978 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -8,6 +8,7 @@ import { import type { Repo } from '../../../shared/types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { isWslUncPath } from '../../../shared/wsl-paths' import type { AppState } from '@/store/types' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' @@ -36,6 +37,29 @@ export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTarget return status === 'local' || status === 'ssh' } +export function isWslStoredAiVaultSessionFile( + sessionFilePath: string | null | undefined +): boolean { + return Boolean(sessionFilePath && isWslUncPath(sessionFilePath)) +} + +export function canResumeAiVaultSessionOnTarget(args: { + sessionFilePath: string | null | undefined + targetStatus: AiVaultResumeTargetStatus +}): boolean { + if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { + return false + } + // Why: vault sessions are scanned from this machine's disk (host home dirs + // plus local WSL homes). An SSH shell can only reach the WSL-stored ones + // (SSH-to-local-WSL setups, #6270); host-stored session files do not exist + // on a remote filesystem, so queuing a resume there is guaranteed to fail. + if (args.targetStatus === 'ssh') { + return isWslStoredAiVaultSessionFile(args.sessionFilePath) + } + return true +} + export function isUnsupportedAiVaultResumeRepo( repo: AiVaultResumeRepoOwner | null | undefined ): boolean { diff --git a/src/renderer/src/lib/ai-vault-session-drag.test.ts b/src/renderer/src/lib/ai-vault-session-drag.test.ts index 6e1a93f763f..e51a0c78efd 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.test.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.test.ts @@ -48,6 +48,7 @@ describe('Session History session drag data', () => { sessionId: 'session-1', title: 'Fix terminal split', command: "cd '/repo' && claude --resume session-1", + sessionFilePath: '/Users/ada/.claude/projects/-repo/session-1.jsonl', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, launchConfig: { agentCommand: 'claude --dangerously-skip-permissions', @@ -63,6 +64,24 @@ describe('Session History session drag data', () => { expect(readAiVaultSessionDragData(transfer)).toEqual(payload) }) + it('rejects blank session file paths', () => { + const transfer = createTransfer() + transfer.setData( + AI_VAULT_SESSION_DRAG_TYPE, + JSON.stringify({ + kind: 'ai-vault-session', + version: 1, + agent: 'claude', + sessionId: 'session-1', + title: 'Blank session file path', + command: 'claude --resume session-1', + sessionFilePath: ' ' + }) + ) + + expect(readAiVaultSessionDragData(transfer)).toBeNull() + }) + it('rejects malformed payloads', () => { const transfer = createTransfer() transfer.setData( diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 1dfede058ec..2b52359e534 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -12,6 +12,9 @@ export type AiVaultSessionDragPayload = { sessionId: string title: string command: string + // Why: drop targets must know where the session file lives (host vs local + // WSL) to reject SSH panes that cannot reach it. + sessionFilePath?: string // Why: drag/drop resume must preserve planned env/default args, not just the shell command. env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -63,6 +66,7 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD isNonEmptyString(payload.sessionId) && isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && + (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) ) @@ -110,12 +114,13 @@ export function readAiVaultSessionDragData( if (!isSerializedPayload(parsed)) { return null } - const { agent, sessionId, title, command, env, launchConfig } = parsed + const { agent, sessionId, title, command, sessionFilePath, env, launchConfig } = parsed return { agent, sessionId, title, command, + ...(sessionFilePath ? { sessionFilePath } : {}), ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}) }