diff --git a/src/renderer/src/components/automations/automation-list-row-identity.test.ts b/src/renderer/src/components/automations/automation-list-row-identity.test.ts index 4dbc19b6944..0bce65d4457 100644 --- a/src/renderer/src/components/automations/automation-list-row-identity.test.ts +++ b/src/renderer/src/components/automations/automation-list-row-identity.test.ts @@ -25,7 +25,12 @@ import { matchAutomationListSearchRowKeys } from './automation-list-search-rows' import { REPO_ID } from './automations-page-fixtures' -import { automationRepoForRow, automationWorktreeForRow } from './automation-list-row-identity' +import { makeFolderWorkspace } from '../sidebar/folder-workspace-composer-submit-fixtures' +import { + automationFolderWorktreeForRow, + automationRepoForRow, + automationWorktreeForRow +} from './automation-list-row-identity' const repoMap = new Map([ [REPO_ID, { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo] @@ -177,4 +182,17 @@ describe('automation list row identity across hosts', () => { }) ).toEqual(['Desktop workspace', 'Runtime workspace']) }) + + it('resolves a folder workspace target inside each row authority', () => { + const rows = collidedListRows().rows + const folders = [ + makeFolderWorkspace({ id: 'fw-1', name: 'Desktop folder' }), + makeFolderWorkspace({ id: 'fw-1', name: 'Runtime folder', executionHostId: 'runtime:gpu' }) + ] + + expect( + rows.map((row) => automationFolderWorktreeForRow(row, folders, 'folder:fw-1')?.displayName) + ).toEqual(['Desktop folder', 'Runtime folder']) + expect(automationFolderWorktreeForRow(rows[0], folders, 'ws-1')).toBeUndefined() + }) }) diff --git a/src/renderer/src/components/automations/automation-list-row-identity.ts b/src/renderer/src/components/automations/automation-list-row-identity.ts index 577f323319f..4b577d5dfe8 100644 --- a/src/renderer/src/components/automations/automation-list-row-identity.ts +++ b/src/renderer/src/components/automations/automation-list-row-identity.ts @@ -19,11 +19,15 @@ import type { AutomationUsageSummary } from '../../../../shared/automation-usage import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' import { + getConnectionExecutionHostId, getRepoExecutionHostId, getWorktreeExecutionHostId } from '../../../../shared/execution-host' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getFolderWorkspaceWorktree } from '../../store/slices/worktrees/listing/detected-worktree-meta' export type AutomationListRow = { /** Authority-qualified and incarnation-free; see the module note. */ @@ -80,15 +84,41 @@ export function automationRepoForRow( if (!authority) { return fallback.get(repoId) } - return repos.find((repo) => { - const host = getRepoExecutionHostId(repo) - return ( - repo.id === repoId && - (authority.kind === 'runtime' - ? host === `runtime:${encodeURIComponent(authority.environmentId)}` - : !host.startsWith('runtime:')) - ) - }) + return repos.find( + (repo) => repo.id === repoId && authorityOwnsHost(authority, getRepoExecutionHostId(repo)) + ) +} + +function authorityOwnsHost( + authority: StableAutomationCatalogRef['authority'], + hostId: string +): boolean { + return authority.kind === 'runtime' + ? hostId === `runtime:${encodeURIComponent(authority.environmentId)}` + : !hostId.startsWith('runtime:') +} + +/** Folder workspaces are not in `worktreesByRepo`; dispatch resolves `folder:` the same way. */ +export function automationFolderWorktreeForRow( + row: AutomationListRow, + folderWorkspaces: readonly FolderWorkspace[], + workspaceId: string | null | undefined = row.automation.workspaceId +): Worktree | undefined { + const scope = workspaceId ? parseWorkspaceKey(workspaceId) : null + if (scope?.type !== 'folder') { + return undefined + } + const authority = row.catalogRef?.authority + const matches = folderWorkspaces.filter( + (workspace) => + workspace.id === scope.folderWorkspaceId && + (!authority || + authorityOwnsHost( + authority, + workspace.executionHostId ?? getConnectionExecutionHostId(workspace.connectionId) + )) + ) + return matches.length === 1 ? getFolderWorkspaceWorktree(matches[0]) : undefined } export function automationWorktreeForRow( diff --git a/src/renderer/src/components/automations/use-automations-page-store-state.folder.test.tsx b/src/renderer/src/components/automations/use-automations-page-store-state.folder.test.tsx new file mode 100644 index 00000000000..b0f394858bc --- /dev/null +++ b/src/renderer/src/components/automations/use-automations-page-store-state.folder.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { makeFolderWorkspace } from '../sidebar/folder-workspace-composer-submit-fixtures' +import { makeAutomation } from './automations-page-fixtures' +import { unscopedAutomationListRows } from './automation-list-row-identity' +import { useAutomationsPageStoreState } from './use-automations-page-store-state' + +const initialState = useAppStore.getState() + +afterEach(() => { + useAppStore.setState(initialState, true) +}) + +describe('automation rows that target a folder workspace', () => { + it('find the folder workspace instead of reporting it gone', () => { + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'fw-1', name: 'App' })] + }) + const [row] = unscopedAutomationListRows([ + makeAutomation({ workspaceMode: 'existing', workspaceId: 'folder:fw-1' }) + ]) + const { result } = renderHook(() => useAutomationsPageStoreState()) + + expect(result.current.worktreeForRow(row, undefined)?.displayName).toBe('App') + }) +}) diff --git a/src/renderer/src/components/automations/use-automations-page-store-state.ts b/src/renderer/src/components/automations/use-automations-page-store-state.ts index 1dbca5005c8..b0737b064d3 100644 --- a/src/renderer/src/components/automations/use-automations-page-store-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-store-state.ts @@ -6,6 +6,7 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { + automationFolderWorktreeForRow, automationRepoForRow, automationWorktreeForRow, type AutomationListRow @@ -18,6 +19,7 @@ export function useAutomationsPageStoreState() { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) @@ -52,8 +54,9 @@ export function useAutomationsPageStoreState() { repo: Repo | undefined, workspaceId: string | null | undefined = row.automation.workspaceId ): Worktree | undefined => + automationFolderWorktreeForRow(row, folderWorkspaces, workspaceId) ?? automationWorktreeForRow(row, worktreesByRepo, repo, worktreeMap, workspaceId), - [worktreeMap, worktreesByRepo] + [folderWorkspaces, worktreeMap, worktreesByRepo] ) const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents) const defaultAgent = diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts index 16a5728131b..491b38df33c 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts @@ -78,6 +78,17 @@ export function folderWorkspaceMatchesHost( ) } +/** Stable synthetic row per folder workspace object, so selectors keep referential equality. */ +export function getFolderWorkspaceWorktree(folderWorkspace: FolderWorkspace): Worktree { + const cached = folderWorkspaceWorktreeCache.get(folderWorkspace) + if (cached) { + return cached + } + const worktree = folderWorkspaceToWorktree(folderWorkspace) + folderWorkspaceWorktreeCache.set(folderWorkspace, worktree) + return worktree +} + export function findKnownWorktreeById( state: Pick< AppState, @@ -105,16 +116,7 @@ export function findKnownWorktreeById( workspace.id === workspaceScope.folderWorkspaceId && (!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId)) ) - if (!folderWorkspace) { - return undefined - } - const cached = folderWorkspaceWorktreeCache.get(folderWorkspace) - if (cached) { - return cached - } - const worktree = folderWorkspaceToWorktree(folderWorkspace) - folderWorkspaceWorktreeCache.set(folderWorkspace, worktree) - return worktree + return folderWorkspace ? getFolderWorkspaceWorktree(folderWorkspace) : undefined } const visible = executionHostId ? (findIndexedWorktreeOwnerForHost(