From 44ea07b09f6522f763d06bc5f3ec22ab6564af12 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sun, 6 Sep 2026 21:34:35 -0700 Subject: [PATCH] fix: extract AI vault resume domains --- ...ured-agent-session-adoption-replay.test.ts | 1 + .../components/right-sidebar/AiVaultPanel.tsx | 61 +---- .../AiVaultSessionVirtualList.tsx | 210 +---------------- .../right-sidebar/AiVaultVirtualRow.tsx | 212 ++++++++++++++++++ .../ai-vault-session-launch-actions.ts | 92 +------- .../ai-vault-session-launch-target.ts | 87 +++++++ ...-vault-session-resume-in-chat-workspace.ts | 64 ++++++ .../ai-vault-session-resume.test.ts | 2 +- 8 files changed, 387 insertions(+), 342 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 497f3828910..42fdbc772a0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -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() }) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index ecc981f2a36..0e0fe1d7a78 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -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] ) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 451b93ca55f..1d52caf8c48 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -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({ ) } - -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 - expandedSessionIds: ReadonlySet - 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 ( -
- {row.type === 'group' ? ( - onToggleGroup(row.group.key)} - /> - ) : ( - 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} - /> - )} -
- ) -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx new file mode 100644 index 00000000000..1c7192654d7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx @@ -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 + expandedSessionIds: ReadonlySet + 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 ( +
+ {row.type === 'group' ? ( + onToggleGroup(row.group.key)} + /> + ) : ( + 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} + /> + )} +
+ ) +} 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 65d62ddf6f9..b04bcb91b36 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 @@ -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 - } - | { 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[0] -): Extract | null { +): Extract, { 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 -): 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') { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts new file mode 100644 index 00000000000..98e136931d5 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts @@ -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 + } + | { 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 +): 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.' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts new file mode 100644 index 00000000000..adedc94b22a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -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 + ) + }) +} 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 583e668cd2a..b5d3fe53c72 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 @@ -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,