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(
+
- {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