fix: extract AI vault resume domains

This commit is contained in:
Merge Sim
2026-09-06 21:34:35 -07:00
parent 3cde95ddbf
commit 44ea07b09f
8 changed files with 387 additions and 342 deletions
@@ -98,6 +98,7 @@ beforeEach(async () => {
afterEach(async () => {
setStructuredAgentSessionHost(null)
await host?.flushAllStreamedEvents()
await host?.close(SESSION)
await rm(root, { recursive: true, force: true })
vi.restoreAllMocks()
})
@@ -29,21 +29,9 @@ import {
resolveAiVaultSessionResumeActions,
resolveAiVaultSessionResumeState
} from './ai-vault-session-resume'
import {
resolveAiVaultTargetWorkspacePath,
useAiVaultSessionLaunchActions
} from './ai-vault-session-launch-actions'
import {
resolveAiVaultSessionResumeInChatEligibility,
type AiVaultResumeInChatEligibility
} from './ai-vault-session-resume-in-chat'
import { structuredAgentLaunchSupported } from '@/lib/agent-launch-routing'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
import { resolveAiVaultSessionResumeInChatForWorkspace } from './ai-vault-session-resume-in-chat-workspace'
import {
useAiVaultSessionWorktreeMap,
withAiVaultCurrentWorktreeStatus
@@ -306,43 +294,14 @@ export default function AiVaultPanel(): React.JSX.Element {
// would run it in". The workspace it targets is the session's own when that is open, because
// Claude looks its transcript up under a directory derived from the launch cwd.
const getSessionResumeInChat = useCallback(
(session: AiVaultSession): AiVaultResumeInChatEligibility => {
const resumeState = getSessionResumeState(session)
const targetWorkspaceId = resumeState.usesSessionWorktree
? resumeState.worktreeId
: (resumeState.worktreeId ?? effectiveActiveWorktreeId)
const targetWorkspacePath = targetWorkspaceId
? resolveAiVaultTargetWorkspacePath(resumeTargetState, targetWorkspaceId)
: null
return resolveAiVaultSessionResumeInChatEligibility({
(session: AiVaultSession): AiVaultResumeInChatEligibility =>
resolveAiVaultSessionResumeInChatForWorkspace({
session,
targetWorkspaceId,
targetWorkspacePath,
structuredRouteAvailable:
isAgentSessionHandleProvider(session.agent) &&
Boolean(targetWorkspaceId) &&
structuredAgentLaunchSupported({
agent: session.agent,
settings,
executionHostId: getExecutionHostIdForWorktree(
useAppStore.getState(),
targetWorkspaceId as string
),
platform: CLIENT_PLATFORM,
hostCapabilities: readLocalRuntimeCapabilities(),
workspaceKind: (targetWorkspaceId as string).startsWith('folder:')
? 'folder'
: 'git-worktree',
projectRuntime: getLocalProjectExecutionRuntimeContext(
useAppStore.getState(),
targetWorkspaceId as string
)
}) &&
readLocalRuntimeCapabilities().includes(
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY
)
})
},
resumeState: getSessionResumeState(session),
activeWorkspaceId: effectiveActiveWorktreeId,
targetState: resumeTargetState,
settings
}),
[effectiveActiveWorktreeId, getSessionResumeState, resumeTargetState, settings]
)
@@ -3,45 +3,28 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list/viewport/virtual-rows'
import { VaultGroupHeader } from './AiVaultPanelControls'
import { EmptyState, SessionLoadingState } from './AiVaultSessionListStates'
import { VaultSessionRow } from './AiVaultSessionRow'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
import {
aiVaultSessionResumeLabel,
aiVaultSessionRowResumeGating,
type AiVaultSessionResumeActions,
type AiVaultSessionResumeState
import type {
AiVaultSessionResumeActions,
AiVaultSessionResumeState
} from './ai-vault-session-resume'
import {
canJumpToAiVaultSessionWorktree,
isAiVaultSessionInCurrentWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
import {
canOpenAiVaultSessionLogInOrca,
canUseLocalAiVaultSessionPathActions
} from './ai-vault-session-path-actions'
import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree'
import {
extractVaultVirtualRowIndexes,
getVaultStickyHeaderIndexes,
VAULT_GROUP_HEADER_ROW_HEIGHT,
VAULT_SESSION_ROW_HEIGHT
} from './ai-vault-virtual-rows'
import { canContinueAiVaultSessionInNewSession } from './ai-vault-session-continuation'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
import { AiVaultVirtualRow, type AiVaultListRow } from './AiVaultVirtualRow'
const VAULT_ROW_OVERSCAN = 8
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 420
type AiVaultListRow =
| { type: 'group'; group: AiVaultSessionGroup }
| { type: 'session'; groupKey: string; session: AiVaultSession }
export function AiVaultSessionVirtualList({
groups,
collapsedGroups,
@@ -246,186 +229,3 @@ export function AiVaultSessionVirtualList({
</div>
)
}
function AiVaultVirtualRow({
row,
index,
start,
activeStickyHeaderIndex,
measureElement,
collapsedGroups,
expandedSessionIds,
vaultScope,
buildResumeStartup,
getOriginalPaneTarget,
getSessionLiveState,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
getSessionResumeInChat,
onToggleGroup,
onToggleSessionDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onContinueInNewSession,
onResumeInNewChat,
onCopyResume,
onCopyId,
onCopyPath,
onOpenLog,
onRevealLog,
onOpenCwd,
onRequestDelete
}: {
row: AiVaultListRow | undefined
index: number
start: number
activeStickyHeaderIndex: number | null
measureElement: (node: Element | null) => void
collapsedGroups: ReadonlySet<string>
expandedSessionIds: ReadonlySet<string>
vaultScope: AiVaultScope
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
getSessionResumeInChat: (session: AiVaultSession) => AiVaultResumeInChatEligibility
onToggleGroup: (key: string) => void
onToggleSessionDetails: (sessionId: string) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void
onResumeInNewChat: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
onOpenLog: (session: AiVaultSession) => void
onRevealLog: (session: AiVaultSession) => void
onOpenCwd: (session: AiVaultSession) => void
onRequestDelete: (session: AiVaultSession) => void
}): React.JSX.Element | null {
if (!row) {
return null
}
const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index
const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null
const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null
// Why: omit the jump affordance when the session already lives in the
// worktree on screen — jumping there is a no-op.
const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo)
const worktreeJumpId =
showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo)
? worktreeInfo?.worktreeId
: null
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
const resumeInChat = row.type === 'session' ? getSessionResumeInChat(row.session) : null
const continuationWorktreeId =
row.type === 'session' &&
canContinueAiVaultSessionInNewSession(row.session, resumeState?.worktreeId)
? resumeState?.worktreeId
: null
// Gate resume on real content: a zero-turn transcript would resume into an
// empty conversation, so it is never offered as normally resumable.
const resumeGating =
row.type === 'session'
? aiVaultSessionRowResumeGating(row.session, resumeState)
: { resumeDisabled: true, canCopyResumeCommand: false }
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
const canOpenLocalSessionPaths =
row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId)
// Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode)
// identities that have no single file to open, while Reveal/CWD stay on the
// existing local-path gate.
const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session)
return (
<div
ref={measureElement}
data-index={index}
className={cn(
'left-0 w-full',
isActiveStickyHeader ? 'sticky top-0 z-10 bg-sidebar' : 'absolute top-0'
)}
style={isActiveStickyHeader ? undefined : { transform: `translateY(${start}px)` }}
>
{row.type === 'group' ? (
<VaultGroupHeader
group={row.group}
collapsed={collapsedGroups.has(row.group.key)}
onToggle={() => onToggleGroup(row.group.key)}
/>
) : (
<VaultSessionRow
session={row.session}
liveState={getSessionLiveState(row.session)}
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
realHomeResumeStartup={buildResumeStartup(
{ ...row.session, codexHome: null },
resumeState?.worktreeId
)}
worktreeInfo={worktreeInfo}
vaultScope={vaultScope}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeGating.resumeDisabled}
resumeLabel={resumeLabel}
resumeActions={
resumeActions ?? {
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: null, disabled: true }
}
}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}
onJumpToOriginalPane={
originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined
}
showJumpToWorktree={showJumpToWorktree}
onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined}
onResume={() => {
if (resumeState?.worktreeId) {
onResume(row.session, resumeState.worktreeId)
}
}}
onContinueInNewSession={
continuationWorktreeId
? () => onContinueInNewSession(row.session, continuationWorktreeId)
: undefined
}
onResumeInNewChat={
resumeInChat?.available
? () => onResumeInNewChat(row.session, resumeInChat.workspaceId)
: undefined
}
onResumeInWorktree={() => {
if (resumeActions?.worktree.worktreeId) {
onResume(row.session, resumeActions.worktree.worktreeId)
}
}}
onResumeInNewTab={() => {
if (resumeActions?.newTab.worktreeId) {
onResume(row.session, resumeActions.newTab.worktreeId)
}
}}
onCopyResume={
resumeGating.canCopyResumeCommand
? () => onCopyResume(row.session, resumeState?.worktreeId)
: undefined
}
onCopyId={() => onCopyId(row.session)}
onCopyPath={() => onCopyPath(row.session)}
onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined}
onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined}
onOpenCwd={
canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined
}
onRequestDelete={onRequestDelete}
/>
)}
</div>
)
}
@@ -0,0 +1,212 @@
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
import { VaultGroupHeader } from './AiVaultPanelControls'
import { VaultSessionRow } from './AiVaultSessionRow'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
import {
aiVaultSessionResumeLabel,
aiVaultSessionRowResumeGating,
type AiVaultSessionResumeActions,
type AiVaultSessionResumeState
} from './ai-vault-session-resume'
import {
canJumpToAiVaultSessionWorktree,
isAiVaultSessionInCurrentWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
import {
canOpenAiVaultSessionLogInOrca,
canUseLocalAiVaultSessionPathActions
} from './ai-vault-session-path-actions'
import { canContinueAiVaultSessionInNewSession } from './ai-vault-session-continuation'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
export type AiVaultListRow =
| { type: 'group'; group: AiVaultSessionGroup }
| { type: 'session'; groupKey: string; session: AiVaultSession }
export function AiVaultVirtualRow({
row,
index,
start,
activeStickyHeaderIndex,
measureElement,
collapsedGroups,
expandedSessionIds,
vaultScope,
buildResumeStartup,
getOriginalPaneTarget,
getSessionLiveState,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
getSessionResumeInChat,
onToggleGroup,
onToggleSessionDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onContinueInNewSession,
onResumeInNewChat,
onCopyResume,
onCopyId,
onCopyPath,
onOpenLog,
onRevealLog,
onOpenCwd,
onRequestDelete
}: {
row: AiVaultListRow | undefined
index: number
start: number
activeStickyHeaderIndex: number | null
measureElement: (node: Element | null) => void
collapsedGroups: ReadonlySet<string>
expandedSessionIds: ReadonlySet<string>
vaultScope: AiVaultScope
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
getSessionResumeInChat: (session: AiVaultSession) => AiVaultResumeInChatEligibility
onToggleGroup: (key: string) => void
onToggleSessionDetails: (sessionId: string) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void
onResumeInNewChat: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
onOpenLog: (session: AiVaultSession) => void
onRevealLog: (session: AiVaultSession) => void
onOpenCwd: (session: AiVaultSession) => void
onRequestDelete: (session: AiVaultSession) => void
}): React.JSX.Element | null {
if (!row) {
return null
}
const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index
const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null
const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null
// Why: omit the jump affordance when the session already lives in the
// worktree on screen — jumping there is a no-op.
const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo)
const worktreeJumpId =
showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo)
? worktreeInfo?.worktreeId
: null
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
const resumeInChat = row.type === 'session' ? getSessionResumeInChat(row.session) : null
const continuationWorktreeId =
row.type === 'session' &&
canContinueAiVaultSessionInNewSession(row.session, resumeState?.worktreeId)
? resumeState?.worktreeId
: null
// Gate resume on real content: a zero-turn transcript would resume into an
// empty conversation, so it is never offered as normally resumable.
const resumeGating =
row.type === 'session'
? aiVaultSessionRowResumeGating(row.session, resumeState)
: { resumeDisabled: true, canCopyResumeCommand: false }
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
const canOpenLocalSessionPaths =
row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId)
// Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode)
// identities that have no single file to open, while Reveal/CWD stay on the
// existing local-path gate.
const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session)
return (
<div
ref={measureElement}
data-index={index}
className={cn(
'left-0 w-full',
isActiveStickyHeader ? 'sticky top-0 z-10 bg-sidebar' : 'absolute top-0'
)}
style={isActiveStickyHeader ? undefined : { transform: `translateY(${start}px)` }}
>
{row.type === 'group' ? (
<VaultGroupHeader
group={row.group}
collapsed={collapsedGroups.has(row.group.key)}
onToggle={() => onToggleGroup(row.group.key)}
/>
) : (
<VaultSessionRow
session={row.session}
liveState={getSessionLiveState(row.session)}
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
realHomeResumeStartup={buildResumeStartup(
{ ...row.session, codexHome: null },
resumeState?.worktreeId
)}
worktreeInfo={worktreeInfo}
vaultScope={vaultScope}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeGating.resumeDisabled}
resumeLabel={resumeLabel}
resumeActions={
resumeActions ?? {
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: null, disabled: true }
}
}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}
onJumpToOriginalPane={
originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined
}
showJumpToWorktree={showJumpToWorktree}
onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined}
onResume={() => {
if (resumeState?.worktreeId) {
onResume(row.session, resumeState.worktreeId)
}
}}
onContinueInNewSession={
continuationWorktreeId
? () => onContinueInNewSession(row.session, continuationWorktreeId)
: undefined
}
onResumeInNewChat={
resumeInChat?.available
? () => onResumeInNewChat(row.session, resumeInChat.workspaceId)
: undefined
}
onResumeInWorktree={() => {
if (resumeActions?.worktree.worktreeId) {
onResume(row.session, resumeActions.worktree.worktreeId)
}
}}
onResumeInNewTab={() => {
if (resumeActions?.newTab.worktreeId) {
onResume(row.session, resumeActions.newTab.worktreeId)
}
}}
onCopyResume={
resumeGating.canCopyResumeCommand
? () => onCopyResume(row.session, resumeState?.worktreeId)
: undefined
}
onCopyId={() => onCopyId(row.session)}
onCopyPath={() => onCopyPath(row.session)}
onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined}
onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined}
onOpenCwd={
canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined
}
onRequestDelete={onRequestDelete}
/>
)}
</div>
)
}
@@ -10,27 +10,23 @@ import {
activateAndRevealWorktree
} from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import {
canResumeAiVaultSessionOnTarget,
getAiVaultResumeWorkspaceExecutionHostId,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation'
import type { Worktree } from '../../../../shared/worktree/types'
import { translate } from '@/i18n/i18n'
import { agentLabel } from './ai-vault-session-filters'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
isKnownAiVaultResumeWorkspaceTarget,
type AiVaultSessionResumeTargetState
} from './ai-vault-session-resume'
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation'
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session'
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import {
aiVaultResumeUnsupportedMessage,
resolveAiVaultSessionLaunchTarget,
resolveAiVaultTargetWorkspacePath
} from './ai-vault-session-launch-target'
export function useAiVaultSessionLaunchActions({
activeWorktree,
@@ -283,66 +279,9 @@ function notifyAiVaultSessionPreparationFailure(error: unknown): void {
)
}
export function resolveAiVaultTargetWorkspacePath(
state: AiVaultSessionResumeTargetState,
workspaceId: string
): string | null {
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
return (
state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId)
?.folderPath ?? null
)
}
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
return findWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null
}
export type AiVaultSessionLaunchTarget =
| { status: 'missing' }
| {
status: 'unsupported'
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
}
| { status: 'ready'; worktreeId: string }
export function resolveAiVaultSessionLaunchTarget(args: {
sessionFilePath: string | null
sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
activeWorktreeId: string | null
targetWorktreeId?: string
targetState: AiVaultSessionResumeTargetState
}): AiVaultSessionLaunchTarget {
const targetWorktreeId = args.targetWorktreeId ?? args.activeWorktreeId
if (
!targetWorktreeId ||
!isKnownAiVaultResumeWorkspaceTarget(args.targetState, targetWorktreeId)
) {
return { status: 'missing' }
}
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(
args.targetState,
targetWorktreeId
)
if (
!canResumeAiVaultSessionOnTarget({
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId: args.sessionExecutionHostId,
targetStatus,
targetExecutionHostId
})
) {
return { status: 'unsupported', targetStatus }
}
return { status: 'ready', worktreeId: targetWorktreeId }
}
function resolveAiVaultSessionLaunchTargetOrNotify(
args: Parameters<typeof resolveAiVaultSessionLaunchTarget>[0]
): Extract<AiVaultSessionLaunchTarget, { status: 'ready' }> | null {
): Extract<ReturnType<typeof resolveAiVaultSessionLaunchTarget>, { status: 'ready' }> | null {
const target = resolveAiVaultSessionLaunchTarget(args)
if (target.status === 'missing') {
toast.error(
@@ -360,23 +299,6 @@ function resolveAiVaultSessionLaunchTargetOrNotify(
return target
}
function aiVaultResumeUnsupportedMessage(
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
): string {
// Why: local and SSH targets can both be valid generally; this branch means
// the session's recorded host does not match the selected workspace.
if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') {
return translate(
'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported',
'This session belongs to a different host. Open a workspace on the same host to resume it.'
)
}
return translate(
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
'Open a workspace before resuming a session.'
)
}
function activateAiVaultResumeWorkspace(workspaceId: string): void {
const workspaceScope = parseWorkspaceKey(workspaceId)
if (workspaceScope?.type === 'folder') {
@@ -0,0 +1,87 @@
import {
canResumeAiVaultSessionOnTarget,
getAiVaultResumeWorkspaceExecutionHostId,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
import { translate } from '@/i18n/i18n'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
isKnownAiVaultResumeWorkspaceTarget,
type AiVaultSessionResumeTargetState
} from './ai-vault-session-resume'
export function resolveAiVaultTargetWorkspacePath(
state: AiVaultSessionResumeTargetState,
workspaceId: string
): string | null {
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
return (
state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId)
?.folderPath ?? null
)
}
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
return findWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null
}
export type AiVaultSessionLaunchTarget =
| { status: 'missing' }
| {
status: 'unsupported'
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
}
| { status: 'ready'; worktreeId: string }
export function resolveAiVaultSessionLaunchTarget(args: {
sessionFilePath: string | null
sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
activeWorktreeId: string | null
targetWorktreeId?: string
targetState: AiVaultSessionResumeTargetState
}): AiVaultSessionLaunchTarget {
const targetWorktreeId = args.targetWorktreeId ?? args.activeWorktreeId
if (
!targetWorktreeId ||
!isKnownAiVaultResumeWorkspaceTarget(args.targetState, targetWorktreeId)
) {
return { status: 'missing' }
}
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(
args.targetState,
targetWorktreeId
)
if (
!canResumeAiVaultSessionOnTarget({
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId: args.sessionExecutionHostId,
targetStatus,
targetExecutionHostId
})
) {
return { status: 'unsupported', targetStatus }
}
return { status: 'ready', worktreeId: targetWorktreeId }
}
export function aiVaultResumeUnsupportedMessage(
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
): string {
// Why: local and SSH targets can both be valid generally; this branch means
// the session's recorded host does not match the selected workspace.
if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') {
return translate(
'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported',
'This session belongs to a different host. Open a workspace on the same host to resume it.'
)
}
return translate(
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
'Open a workspace before resuming a session.'
)
}
@@ -0,0 +1,64 @@
import {
structuredAgentLaunchSupported,
type AgentLaunchRoutingInput
} from '@/lib/agent-launch-routing'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
import { useAppStore } from '@/store'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target'
import {
resolveAiVaultSessionResumeInChatEligibility,
type AiVaultResumeInChatEligibility
} from './ai-vault-session-resume-in-chat'
import type {
AiVaultSessionResumeState,
AiVaultSessionResumeTargetState
} from './ai-vault-session-resume'
export function resolveAiVaultSessionResumeInChatForWorkspace(args: {
session: AiVaultSession
resumeState: AiVaultSessionResumeState
activeWorkspaceId: string | null
targetState: AiVaultSessionResumeTargetState
settings: AgentLaunchRoutingInput['settings']
}): AiVaultResumeInChatEligibility {
const targetWorkspaceId = args.resumeState.usesSessionWorktree
? args.resumeState.worktreeId
: (args.resumeState.worktreeId ?? args.activeWorkspaceId)
const targetWorkspacePath = targetWorkspaceId
? resolveAiVaultTargetWorkspacePath(args.targetState, targetWorkspaceId)
: null
return resolveAiVaultSessionResumeInChatEligibility({
session: args.session,
targetWorkspaceId,
targetWorkspacePath,
structuredRouteAvailable:
isAgentSessionHandleProvider(args.session.agent) &&
Boolean(targetWorkspaceId) &&
structuredAgentLaunchSupported({
agent: args.session.agent,
settings: args.settings,
executionHostId: getExecutionHostIdForWorktree(
useAppStore.getState(),
targetWorkspaceId as string
),
platform: CLIENT_PLATFORM,
hostCapabilities: readLocalRuntimeCapabilities(),
workspaceKind: (targetWorkspaceId as string).startsWith('folder:')
? 'folder'
: 'git-worktree',
projectRuntime: getLocalProjectExecutionRuntimeContext(
useAppStore.getState(),
targetWorkspaceId as string
)
}) &&
readLocalRuntimeCapabilities().includes(
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY
)
})
}
@@ -3,7 +3,7 @@ import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { resolveAiVaultSessionLaunchTarget } from './ai-vault-session-launch-actions'
import { resolveAiVaultSessionLaunchTarget } from './ai-vault-session-launch-target'
import {
aiVaultSessionResumeLabel,
aiVaultSessionRowResumeGating,