diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index ae5199d20b8..d14ad2635d4 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -190,6 +190,7 @@ Done means: reviewers and future agents can understand the vision, implementatio - [x] 2026-06-12: Added repo-backed Tasks empty-state copy that distinguishes missing project sources from no matching GitHub/GitLab work, then verified with focused empty-state tests and web typecheck. - [x] 2026-06-12: Scoped Jira Tasks drawer/list cache reconciliation by Jira task source context and site id so same-key issues from different hosts/accounts cannot replace each other; verified with focused cache selector tests, targeted oxlint, and full typecheck. - [x] 2026-06-12: Seeded New Workspace run-target selection from the incoming task source context so Linear/Jira starts preselect the logical project/source host instead of the ambient active repo; verified with a focused composer host-boundary test, targeted oxlint, and full typecheck. +- [x] 2026-06-12: Added explicit disabled-reason copy for external automation actions when an SSH host is disconnected/connecting, another action is in flight, or the local/remote automation source tool is missing; verified with focused availability tests, targeted oxlint, git diff --check, and full typecheck. - [x] 2026-06-12: Fed Tasks source-context diagnostics from the shared execution-host registry so the source chip now reports SSH availability and blocked/incompatible remote-server hosts; verified with summary tests and web typecheck. - [x] 2026-06-12: Extended Linear/Jira Tasks source diagnostics to include the account-owning host plus blocked remote-server availability, keeping account source separate from workspace run host; verified with summary tests and web typecheck. - [x] 2026-06-12: Started the user-facing Host copy audit by replacing stale “remote project” and “Remote SSH” UI labels with project-on-host / SSH-host language; verified with web typecheck. diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index a5ce39b8d58..b6a3266d6d9 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -90,6 +90,7 @@ import { AutomationRunHistory } from './AutomationRunHistory' import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' import { + getExternalAutomationActionDisabledMessage, getExternalAutomationSourceAvailability, isSshConnectionBusy } from './external-automation-source-availability' @@ -2006,7 +2007,18 @@ export default function AutomationsPage(): React.JSX.Element { const nextRunLabel = entry.job.enabled ? formatExternalDate(entry.job.nextRunAt, relativeNow) : 'Paused' - const actionDisabled = !entry.manager.canManage || externalActionKey !== null + const entrySshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus: entrySshStatus, + actionInProgress: externalActionKey !== null + }) + const actionDisabled = disabledMessage !== null const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) return ( @@ -2069,10 +2081,13 @@ export default function AutomationsPage(): React.JSX.Element { onSelect={() => requestExternalAction(entry.manager, entry.job, 'run')} > - {translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - )} + + {disabledMessage ?? + translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + )} + {entry.manager.provider === 'hermes' ? ( {manager.jobs.map((job) => { const scheduleDisplay = getExternalAutomationScheduleDisplay(manager, job) + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager, + actionInProgress: runningActionKey !== null + }) return (
onAction(manager, job, 'run')} > {runningActionKey === actionKey(manager, job, 'run') ? ( @@ -243,11 +251,14 @@ export function ExternalAutomationManagers({ {manager.provider === 'hermes' ? ( onEdit?.(manager, job)} > @@ -255,7 +266,8 @@ export function ExternalAutomationManagers({ ) : null} onAction(manager, job, job.enabled ? 'pause' : 'resume')} > {runningActionKey === @@ -278,12 +290,15 @@ export function ExternalAutomationManagers({ )} onAction(manager, job, 'delete')} > {runningActionKey === actionKey(manager, job, 'delete') ? ( diff --git a/src/renderer/src/components/automations/external-automation-source-availability.test.ts b/src/renderer/src/components/automations/external-automation-source-availability.test.ts index 0c6b98e7aef..37f895ea6fe 100644 --- a/src/renderer/src/components/automations/external-automation-source-availability.test.ts +++ b/src/renderer/src/components/automations/external-automation-source-availability.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { ExternalAutomationManager } from '../../../../shared/automations-types' -import { getExternalAutomationSourceAvailability } from './external-automation-source-availability' +import { + getExternalAutomationActionDisabledMessage, + getExternalAutomationSourceAvailability +} from './external-automation-source-availability' function manager(overrides: Partial = {}): ExternalAutomationManager { return { @@ -96,4 +99,71 @@ describe('external automation source availability', () => { isConnecting: true }) }) + + it('explains disabled local automation actions when the source tool is missing', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ error: 'Hermes jobs were found, but the hermes CLI is not on PATH.' }) + }) + ).toBe('Hermes jobs were found, but the hermes CLI is not on PATH.') + }) + + it('explains disabled SSH automation actions before the host is connected', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'SSH target is not connected.' + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'disconnected' + }) + ).toBe('Connect this ssh host before managing Hermes automations.') + }) + + it('explains disabled SSH automation actions while the host is connecting', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'SSH target is not connected.' + }), + targetKindLabel: 'SSH host', + sshStatus: 'deploying-relay' + }) + ).toBe('Wait for this ssh host to finish connecting.') + }) + + it('explains disabled SSH automation actions when the remote source tool is missing', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'Hermes CLI is not on the remote PATH.' + }), + sshStatus: 'connected' + }) + ).toBe('Hermes CLI is not on the remote PATH.') + }) + + it('keeps concrete remote source errors when SSH status is unavailable to the caller', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'Hermes CLI is not on the remote PATH.' + }) + }) + ).toBe('Hermes CLI is not on the remote PATH.') + }) + + it('explains disabled actions while another automation action is running', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ canManage: true }), + actionInProgress: true + }) + ).toBe('Another automation action is still running.') + }) }) diff --git a/src/renderer/src/components/automations/external-automation-source-availability.ts b/src/renderer/src/components/automations/external-automation-source-availability.ts index 79e9e8452bc..92f0749f892 100644 --- a/src/renderer/src/components/automations/external-automation-source-availability.ts +++ b/src/renderer/src/components/automations/external-automation-source-availability.ts @@ -1,4 +1,7 @@ -import type { ExternalAutomationManager } from '../../../../shared/automations-types' +import type { + ExternalAutomationManager, + ExternalAutomationProvider +} from '../../../../shared/automations-types' import type { SshConnectionStatus } from '../../../../shared/ssh-types' export type ExternalAutomationSourceAvailability = { @@ -74,3 +77,48 @@ export function getExternalAutomationSourceAvailability({ export function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean { return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' } + +export function getExternalAutomationActionDisabledMessage(args: { + manager: ExternalAutomationManager + providerLabel?: string + targetKindLabel?: string + sshStatus?: SshConnectionStatus + actionInProgress?: boolean +}): string | null { + if (args.actionInProgress) { + return 'Another automation action is still running.' + } + if (args.manager.canManage) { + return null + } + const providerLabel = args.providerLabel ?? getProviderLabel(args.manager.provider) + const targetKindLabel = + args.targetKindLabel ?? (args.manager.target.type === 'ssh' ? 'SSH host' : 'Local') + if (args.manager.target.type === 'ssh') { + if (isSshConnectionBusy(args.sshStatus)) { + return `Wait for this ${targetKindLabel.toLowerCase()} to finish connecting.` + } + if (args.manager.error && !isSshDisconnectedError(args.manager.error)) { + return args.manager.error + } + if (args.sshStatus !== 'connected') { + return `Connect this ${targetKindLabel.toLowerCase()} before managing ${providerLabel} automations.` + } + return ( + args.manager.error ?? + `${providerLabel} cannot manage automations on this ${targetKindLabel.toLowerCase()}.` + ) + } + return ( + args.manager.error ?? + `${providerLabel} cannot manage automations on this ${targetKindLabel.toLowerCase()}.` + ) +} + +function getProviderLabel(provider: ExternalAutomationProvider): string { + return provider === 'hermes' ? 'Hermes' : 'OpenClaw' +} + +function isSshDisconnectedError(message: string): boolean { + return /ssh target is not connected/i.test(message) +}