diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index 936194da5fa..0bfa91176be 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -49,7 +49,7 @@ Done means: if Machine A and Machine B have different provider accounts, every l - [~] Show enough account/host metadata to make mismatched GitHub/GitLab/Linear/Jira accounts understandable. - [~] Persist task source context in task drawer URLs/state so refresh, deep links, and reopen preserve the same source. - [x] Ensure task drawer actions reuse the drawer item source context, not a global provider default. -- [~] Add disabled/auth-needed/disconnected states for source hosts. +- [x] Add disabled/auth-needed/disconnected states for source hosts. - [x] Add empty states explaining when a project has no configured task source on the selected host. Done means: users can tell where Tasks are being fetched from, and changing execution host does not silently change provider data. @@ -245,3 +245,4 @@ Done means: reviewers and future agents can understand the vision, implementatio - [x] 2026-06-12: Continued the Host copy audit through onboarding, remote folder-add, attachment import, Settings SSH copy, and remote file browser catalog text; extracted `RepoStepNestedImportPanel` to keep the touched onboarding component under max-lines, and verified with stale-copy scans, focused onboarding/repos tests, focused oxlint, full `pnpm run typecheck`, and `git diff --check`. - [x] 2026-06-12: Preserved explicit automation run-host context in CLI project selectors: `orca automations create/edit --project/--host` and `--project-host-setup` now send `WorkspaceRunContext` alongside the legacy repo selector, so scheduled runs retain logical project, host, setup, repo, and path identity. Verified with focused CLI regressions (108 tests), targeted oxlint, full `pnpm run typecheck`, and `git diff --check`. - [x] 2026-06-12: Added an advanced automation CLI source selector via `--source-context ` for create/edit, normalizing the shared `TaskSourceContext` shape before RPC and allowing edit callers to clear the source context with `null`; verified with focused CLI regressions (111 tests), targeted oxlint, full `pnpm run typecheck`, and `git diff --check`. +- [x] 2026-06-12: Added a reusable Tasks source availability notice and wired unavailable source-host/account states into the Tasks source chrome, so disconnected/auth-needed/blocked hosts show an inline status, fully unavailable sources disable their icon, and partial multi-host failures remain selectable with warning copy; verified with focused source-summary tests, targeted oxlint, and full `pnpm run typecheck`. diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index e96ec9d06d5..c88d61a8f0e 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -90,8 +90,14 @@ import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/I import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' import { LinearPriorityIcon } from '@/components/linear-priority-icon' import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection' -import { getTaskSourceContextSummary } from './task-source-context-summary' -import type { TaskSourceHostAvailability } from './task-source-context-summary' +import { + getTaskSourceAvailabilityNotice, + getTaskSourceContextSummary +} from './task-source-context-summary' +import type { + TaskSourceAvailabilityNotice, + TaskSourceHostAvailability +} from './task-source-context-summary' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { getGitHubPRPrimaryReviewer, @@ -2968,6 +2974,67 @@ export default function TaskPage(): React.JSX.Element { } ] }, [accountBackedTaskSourceHostId, hostRegistryById, taskSource]) + const taskSourceAvailabilityNoticeByProvider = useMemo< + Partial> + >(() => { + const availabilityForContexts = ( + contexts: readonly TaskSourceContext[] + ): TaskSourceHostAvailability[] => + contexts.flatMap((context) => { + const host = hostRegistryById.get(context.hostId) + if (!host || host.health === 'local' || host.health === 'available') { + return [] + } + return [{ hostId: context.hostId, health: host.health, status: host.connectionStatus }] + }) + const accountHost = hostRegistryById.get(accountBackedTaskSourceHostId) + const accountAvailability = + !accountHost || accountHost.health === 'local' || accountHost.health === 'available' + ? [] + : [ + { + hostId: accountHost.id, + health: accountHost.health, + status: accountHost.connectionStatus + } + ] + const labelFor = (provider: TaskProvider): string => + sourceOptions.find((source) => source.id === provider)?.label ?? provider + return { + github: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('github'), + sourceCount: selectedRepos.length, + hostAvailability: availabilityForContexts( + selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, 'github')) + .filter((context): context is TaskSourceContext => context !== null) + ) + }) ?? undefined, + gitlab: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('gitlab'), + sourceCount: selectedRepos.length, + hostAvailability: availabilityForContexts( + selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, 'gitlab')) + .filter((context): context is TaskSourceContext => context !== null) + ) + }) ?? undefined, + linear: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('linear'), + sourceCount: 1, + hostAvailability: accountAvailability + }) ?? undefined, + jira: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('jira'), + sourceCount: 1, + hostAvailability: accountAvailability + }) ?? undefined + } + }, [accountBackedTaskSourceHostId, hostRegistryById, selectedRepos, sourceOptions]) const taskSourceContextSummary = useMemo(() => { const providerLabel = sourceOptions.find((source) => source.id === taskSource)?.label ?? taskSource @@ -2996,6 +3063,27 @@ export default function TaskPage(): React.JSX.Element { taskSourceHostAvailability, taskSourceRepoContexts ]) + const taskSourceAvailabilityNotice = useMemo(() => { + const providerLabel = + sourceOptions.find((source) => source.id === taskSource)?.label ?? taskSource + return getTaskSourceAvailabilityNotice({ + providerLabel, + sourceCount: + taskSource === 'linear' || taskSource === 'jira' + ? 1 + : Math.max(1, taskSourceRepoContexts.length), + hostAvailability: + taskSource === 'linear' || taskSource === 'jira' + ? accountBackedTaskSourceHostAvailability + : taskSourceHostAvailability + }) + }, [ + accountBackedTaskSourceHostAvailability, + sourceOptions, + taskSource, + taskSourceHostAvailability, + taskSourceRepoContexts.length + ]) const githubEmptyState = useMemo( () => getRepoBackedTaskEmptyState({ @@ -7212,13 +7300,19 @@ export default function TaskPage(): React.JSX.Element {
{visibleSourceOptions.map((source) => { const active = taskSource === source.id + const sourceAvailabilityNotice = + taskSourceAvailabilityNoticeByProvider[source.id] ?? null + const sourceDisabled = source.disabled || sourceAvailabilityNotice?.blocking return ( - {source.label} + {sourceAvailabilityNotice?.label ?? source.label} ) @@ -7356,6 +7450,17 @@ export default function TaskPage(): React.JSX.Element { ) : null}
+ {taskSourceAvailabilityNotice ? ( +
+ + {taskSourceAvailabilityNotice.label} +
+ ) : null} + {taskSource === 'github' ? (
{projectModeVisible ? ( diff --git a/src/renderer/src/components/task-source-context-summary.test.ts b/src/renderer/src/components/task-source-context-summary.test.ts index d84a4eadbc7..cac6f0d0917 100644 --- a/src/renderer/src/components/task-source-context-summary.test.ts +++ b/src/renderer/src/components/task-source-context-summary.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getTaskSourceContextSummary } from './task-source-context-summary' +import { + getTaskSourceAvailabilityNotice, + getTaskSourceContextSummary +} from './task-source-context-summary' describe('task source context summary', () => { it('shows provider, host, and provider identity for a single repo-backed source', () => { @@ -173,4 +176,28 @@ describe('task source context summary', () => { 'Linear source · Host: old-server · Availability: old-server server update needed · Account: Stably' ) }) + + it('builds a visible unavailable-source notice from host availability', () => { + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + hostAvailability: [{ hostId: 'ssh:devbox', status: 'auth-failed' }] + }) + ).toEqual({ + label: 'GitHub source unavailable: devbox auth needed', + title: 'Reconnect or update devbox auth needed to load this source.', + blocking: true + }) + + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitLab', + sourceCount: 3, + hostAvailability: [ + { hostId: 'ssh:devbox', status: 'disconnected' }, + { hostId: 'runtime:old-server', health: 'blocked' } + ] + })?.label + ).toBe('Some GitLab source hosts unavailable: 2 source hosts') + }) }) diff --git a/src/renderer/src/components/task-source-context-summary.ts b/src/renderer/src/components/task-source-context-summary.ts index 821162bccd2..bc6fbbef6f7 100644 --- a/src/renderer/src/components/task-source-context-summary.ts +++ b/src/renderer/src/components/task-source-context-summary.ts @@ -10,6 +10,12 @@ export type TaskSourceContextSummary = { title: string } +export type TaskSourceAvailabilityNotice = { + label: string + title: string + blocking: boolean +} + export type TaskSourceHostAvailability = { hostId: ExecutionHostScope status?: SshConnectionStatus @@ -45,6 +51,29 @@ export function getTaskSourceContextSummary(args: { } } +export function getTaskSourceAvailabilityNotice(args: { + providerLabel: string + hostAvailability?: readonly TaskSourceHostAvailability[] + sourceCount?: number +}): TaskSourceAvailabilityNotice | null { + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? []) + if (unavailableHosts.length === 0) { + return null + } + const sourceCount = Math.max(args.sourceCount ?? unavailableHosts.length, unavailableHosts.length) + const blocking = unavailableHosts.length >= sourceCount + const hostStatusLabels = unavailableHosts.map((host) => `${host.hostLabel} ${host.statusLabel}`) + const target = + unavailableHosts.length === 1 ? hostStatusLabels[0] : `${unavailableHosts.length} source hosts` + return { + label: blocking + ? `${args.providerLabel} source unavailable: ${target}` + : `Some ${args.providerLabel} source hosts unavailable: ${target}`, + title: `Reconnect or update ${formatLongList(hostStatusLabels)} to load this source.`, + blocking + } +} + function getRepoBackedTaskSourceSummary(args: { providerLabel: string repoContexts?: readonly TaskSourceContext[]