From 8fa6735b9c19860d818e6a25ead2f99528145eb4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:58:36 -0700 Subject: [PATCH] Filter automation create projects by destination host Only offer projects available on the selected destination, preventing the mismatches that would fail at submit time. Auto-adjust the project selection if it becomes unavailable when the destination changes. --- .../AutomationCreateDestinationField.test.tsx | 86 +++++++++++++++++++ .../AutomationCreateDestinationField.tsx | 32 ++++--- ...utomationsPage.create-destination.test.tsx | 86 +++++++++++++++++++ .../automations/AutomationsPage.tsx | 32 +++++-- .../automation-create-destination.ts | 14 +++ .../automations-page-test-harness.tsx | 2 + .../use-automation-create-destination.ts | 28 +++++- src/renderer/src/i18n/locales/en.json | 3 +- 8 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx new file mode 100644 index 00000000000..f194c95039d --- /dev/null +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { AutomationCreateDestinationField } from './AutomationCreateDestinationField' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationCreateDestinationControl } from './use-automation-create-destination' +import type { Repo } from '../../../../shared/types' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +const OWNER: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 1 } +} + +const ENTRY: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: OWNER, + stableKey: 'host:desktop:ssh:t1', + label: 'openclaw', + authorityLabel: 'Local Mac', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' +} + +function control(projects: Repo[]): AutomationCreateDestinationControl { + return { + entries: [ENTRY], + resolution: { + status: 'ready', + authority: OWNER.authority, + destination: { selector: OWNER.selector }, + entry: ENTRY + }, + onSelect: () => undefined, + projects + } +} + +function render(projects: Repo[]): void { + act(() => { + root.render( + + + + ) + }) +} + +function emptyNote(): HTMLElement | null { + return container.querySelector('[data-testid="automation-create-no-projects"]') +} + +describe('AutomationCreateDestinationField', () => { + it('names the host that has no projects rather than leaving Create dead', () => { + render([]) + + expect(emptyNote()?.textContent).toContain('openclaw') + }) + + it('states the storing authority once the host has a project to offer', () => { + render([{ id: 'repo-1' } as Repo]) + + expect(emptyNote()).toBeNull() + expect(container.textContent).toContain('Local Mac') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx index 44bae0fb08a..1284236983e 100644 --- a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx @@ -67,17 +67,27 @@ export function AutomationCreateDestinationField({ ))} -

- {selected - ? translate( - 'auto.components.automations.createDestination.storedOn', - 'Stored and scheduled by {authority}.' - ).replace('{authority}', selected.authorityLabel) - : translate( - 'auto.components.automations.createDestination.unselected', - 'Choose the host this automation will be created on.' - )} -

+ {selected && control.projects.length === 0 ? ( + // Otherwise Create is disabled for an empty project list with nothing said. +

+ {translate( + 'auto.components.automations.createDestination.noProjects', + 'No projects are set up on {host}. Add one there, or choose another host.' + ).replace('{host}', selected.label)} +

+ ) : ( +

+ {selected + ? translate( + 'auto.components.automations.createDestination.storedOn', + 'Stored and scheduled by {authority}.' + ).replace('{authority}', selected.authorityLabel) + : translate( + 'auto.components.automations.createDestination.unselected', + 'Choose the host this automation will be created on.' + )} +

+ )} ) } diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index ea82fce1f9a..e866e2d85dc 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -36,6 +36,12 @@ installAutomationsPageHarness() const RUNTIME_REPO_ID = 'repo-2' const RUNTIME_WORKSPACE_ID = 'workspace-2' +const SSH_TARGET_ID = 'ssh-target-1' +const SSH_REPO_ID = 'repo-ssh' +const SSH_HOST_KEY = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: SSH_TARGET_ID } +}) const DESKTOP_SELF_KEY = hostStableKey({ authority: { kind: 'desktop' }, @@ -90,6 +96,28 @@ function addRuntimeProject(): void { mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree) } +/** A desktop-registered SSH host, with the generation that makes it fenceable. */ +function addSshHost(): void { + mocks.state.sshTargetLabels = new Map([[SSH_TARGET_ID, 'openclaw']]) + mocks.state.sshTargetGenerations = new Map([[SSH_TARGET_ID, 1]]) + mocks.state.sshConnectionStates = new Map([[SSH_TARGET_ID, { status: 'connected' }]]) +} + +/** A project checked out on that SSH host rather than locally. */ +function addSshProject(): void { + const repo = { + id: SSH_REPO_ID, + displayName: 'orca', + path: '/repos/orca', + badgeColor: '#222222', + addedAt: 1, + worktreeBaseRef: 'main', + connectionId: SSH_TARGET_ID + } as Repo + mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] + mocks.repoMap.set(SSH_REPO_ID, repo) +} + /** The runtime answers a create; without this the RPC double returns an empty result. */ function runtimeCreateReturns(automation: Automation): void { const previous = mocks.callRuntimeRpc.getMockImplementation() @@ -195,6 +223,64 @@ describe('AutomationsPage create destination', () => { expect(api.automations.create).not.toHaveBeenCalled() }) + it('offers only the projects the chosen host actually has', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + addSshHost() + addSshProject() + + await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY) + }) + + // The local project shares this one's name, so offering both leaves the user + // no way to tell which is the one on that host. + expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID]) + }) + + it('moves a stranded project to one the newly chosen host has', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + addSshHost() + addSshProject() + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + expect(mocks.editorDialog?.draft?.projectId).toBe(REPO_ID) + + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY) + }) + + // Keeping the local project selected only defers the same refusal to submit. + expect(mocks.editorDialog?.draft?.projectId).toBe(SSH_REPO_ID) + }) + + it('offers nothing rather than a stranded project when the host has no projects', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + addSshHost() + + await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY) + }) + + expect(mocks.editorDialog?.repos).toEqual([]) + // Nothing is left selected to submit against a host that cannot hold it. + expect(mocks.editorDialog?.draft?.projectId).toBe('') + }) + it('states the chosen host on the form before submit', async () => { api.automations.list.mockResolvedValue([]) scopedList([]) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 37ecb45082f..5d4852468de 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -578,8 +578,22 @@ export default function AutomationsPage(): React.JSX.Element { // Non-null only for a concrete host filter, which constrains the destination. filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null, activeWorkspaceStableKey: activeWorkspaceHostStableKey, - repoTables + repoTables, + projects: repos }) + const editorProjects = createDestination.control.projects + // A destination change can strand the chosen project on another host; leaving it + // selected only defers the same refusal to submit. + useEffect(() => { + if (!createOpen || editingAutomationId !== null || createTarget !== 'orca') { + return + } + setDraft((current) => + !current.projectId || editorProjects.some((project) => project.id === current.projectId) + ? current + : { ...current, projectId: '', workspaceId: '', baseBranch: '' } + ) + }, [createOpen, createTarget, editingAutomationId, editorProjects]) // The row's own host, from its captured owner. A page-level target cannot // speak for a list spanning authorities, and the legacy arm below it is only // ever reached by rows the desktop's unscoped list produced. @@ -848,17 +862,25 @@ export default function AutomationsPage(): React.JSX.Element { const getDefaultTarget = useCallback(() => { const activeWorktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null const activeRepo = activeWorktree ? (repoMap.get(activeWorktree.repoId) ?? null) : null - const fallbackRepo = activeRepo ?? repos[0] ?? null + // The stated destination decides which projects exist for this draft, so an + // active workspace on another host is not a candidate here. + const eligibleActiveRepo = + activeRepo && editorProjects.some((project) => project.id === activeRepo.id) + ? activeRepo + : null + const fallbackRepo = eligibleActiveRepo ?? editorProjects[0] ?? null const fallbackWorktrees = fallbackRepo ? (worktreesByRepo[fallbackRepo.id] ?? []) : [] // Why: automation-created workspaces can be active; new automations should start from // the repo's stable main worktree unless the user explicitly chooses otherwise. - const targetWorktree = getDefaultWorktree(fallbackWorktrees) ?? activeWorktree + const targetWorktree = + getDefaultWorktree(fallbackWorktrees) ?? + (activeWorktree && activeWorktree.repoId === fallbackRepo?.id ? activeWorktree : null) const targetProjectId = fallbackRepo?.id ?? targetWorktree?.repoId ?? '' return { projectId: targetProjectId, workspaceId: targetWorktree?.id ?? '' } - }, [activeWorktreeId, repoMap, repos, worktreeMap, worktreesByRepo]) + }, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo]) const reloadExternalManagers = scopedExternal.reload @@ -1951,7 +1973,7 @@ export default function AutomationsPage(): React.JSX.Element { canSave={canSaveDraft} isEditingExternal={editingExternalTarget !== null} createTarget={createTarget} - repos={repos} + repos={editorProjects} projectHostSetups={projectHostSetups} automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} getAutomationHooksCacheKey={getAutomationHooksCacheKey} diff --git a/src/renderer/src/components/automations/automation-create-destination.ts b/src/renderer/src/components/automations/automation-create-destination.ts index dcafac0c8a4..c3c4b2a94e9 100644 --- a/src/renderer/src/components/automations/automation-create-destination.ts +++ b/src/renderer/src/components/automations/automation-create-destination.ts @@ -162,6 +162,20 @@ export function automationCreateProjectMismatch( return selector.kind === 'ssh' ? connectionId !== selector.targetId : connectionId !== null } +/** + * The projects a destination can hold, filtered by the rule submit already + * enforces, so the form cannot offer a pairing its own check will refuse. + */ +export function automationCreateEligibleProjects( + tables: AutomationAuthorityRepoTables, + destination: AutomationCreateDestination, + projects: readonly TProject[] +): TProject[] { + return projects.filter( + (project) => !automationCreateProjectMismatch(tables, destination, project.id) + ) +} + function destinationOwner(value: AutomationCreateDestination): AutomationOwnerRef { return { authority: value.authority, selector: value.destination.selector } } diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index f8babde3716..8085b46e1e1 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -87,6 +87,8 @@ export type EditorDialogProps = { isEditing: boolean createDestination?: AutomationCreateDestinationControl notice?: { message: string } | null + repos?: { id: string }[] + draft?: { projectId: string; workspaceId: string } onSave: () => void onDraftChange: (updater: (current: unknown) => unknown) => void } diff --git a/src/renderer/src/components/automations/use-automation-create-destination.ts b/src/renderer/src/components/automations/use-automation-create-destination.ts index 1b79000a6a3..615c05ffc43 100644 --- a/src/renderer/src/components/automations/use-automation-create-destination.ts +++ b/src/renderer/src/components/automations/use-automation-create-destination.ts @@ -10,12 +10,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../shared/types' import type { AutomationHostCatalog, AutomationHostCatalogEntry } from './automation-host-catalog-types' import type { AutomationAuthorityRepoTables } from './automation-authority-identity' import { + automationCreateEligibleProjects, automationCreateProjectMismatch, preselectAutomationCreateHost, resolveAutomationCreateDestination, @@ -31,6 +33,8 @@ export type AutomationCreateDestinationControl = { entries: readonly AutomationHostCatalogEntry[] resolution: AutomationCreateDestinationResolution onSelect: (stableKey: string) => void + /** Projects the resolved destination can hold; the whole list until one resolves. */ + projects: readonly Repo[] } export type AutomationCreateDestinationCheck = @@ -46,6 +50,7 @@ export type AutomationCreateDestinationInput = { filterStableKey: string | null activeWorkspaceStableKey: string | null repoTables: AutomationAuthorityRepoTables + projects: readonly Repo[] } export type AutomationCreateDestinationState = { @@ -79,7 +84,15 @@ function ownerNotice(message: string): AutomationActionNotice { export function useAutomationCreateDestination( input: AutomationCreateDestinationInput ): AutomationCreateDestinationState { - const { open, catalog, entries, filterStableKey, activeWorkspaceStableKey, repoTables } = input + const { + open, + catalog, + entries, + filterStableKey, + activeWorkspaceStableKey, + repoTables, + projects + } = input const [captured, setCaptured] = useState(null) const [reason, setReason] = useState('unselected') @@ -169,8 +182,19 @@ export function useAutomationCreateDestination( [captured, reason] ) + const eligibleProjects = useMemo( + () => + resolution.status === 'ready' + ? automationCreateEligibleProjects(repoTables, resolution, projects) + : projects, + [projects, repoTables, resolution] + ) + return { - control: useMemo(() => ({ entries, resolution, onSelect }), [entries, onSelect, resolution]), + control: useMemo( + () => ({ entries, resolution, onSelect, projects: eligibleProjects }), + [eligibleProjects, entries, onSelect, resolution] + ), check } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 025e1b0f22d..7ceed9367eb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15495,7 +15495,8 @@ "orphan": "Automations with no host cannot hold new ones. Choose a host to create this automation on.", "unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.", "stale": "{host} changed while this form was open. Choose it again before saving.", - "projectMismatch": "That project is not on {host}. Choose a project on that host, or another host." + "projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.", + "noProjects": "No projects are set up on {host}. Add one there, or choose another host." } }, "agent": {