Clarify unavailable task source hosts

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 21:57:40 -07:00
co-authored by Orca
parent d1c773dc1b
commit e85d09ca15
4 changed files with 170 additions and 8 deletions
@@ -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 <json|null>` 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`.
+111 -6
View File
@@ -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<Record<TaskProvider, TaskSourceAvailabilityNotice>>
>(() => {
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 {
<div className="mx-1 h-5 w-px bg-border/50" aria-hidden />
{visibleSourceOptions.map((source) => {
const active = taskSource === source.id
const sourceAvailabilityNotice =
taskSourceAvailabilityNoticeByProvider[source.id] ?? null
const sourceDisabled = source.disabled || sourceAvailabilityNotice?.blocking
return (
<Tooltip key={source.id}>
<TooltipTrigger asChild>
<button
type="button"
disabled={source.disabled}
disabled={sourceDisabled}
onClick={() => {
if (sourceAvailabilityNotice?.blocking) {
return
}
taskSourceManuallyChangedRef.current = true
openTaskPage(
{ taskSource: source.id },
@@ -7233,20 +7327,20 @@ export default function TaskPage(): React.JSX.Element {
)
})
}}
aria-label={source.label}
aria-label={sourceAvailabilityNotice?.label ?? source.label}
className={cn(
'group flex h-8 w-8 items-center justify-center rounded-md border transition',
active
? 'border-foreground/40 bg-muted/70 text-foreground shadow-sm'
: 'border-border/40 bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground',
source.disabled && 'cursor-not-allowed opacity-55'
sourceDisabled && 'cursor-not-allowed opacity-55'
)}
>
<source.Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{source.label}
{sourceAvailabilityNotice?.label ?? source.label}
</TooltipContent>
</Tooltip>
)
@@ -7356,6 +7450,17 @@ export default function TaskPage(): React.JSX.Element {
) : null}
</div>
{taskSourceAvailabilityNotice ? (
<div
role="status"
className="flex max-w-3xl items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
title={taskSourceAvailabilityNotice.title}
>
<AlertCircle className="size-3.5 flex-none" />
<span className="min-w-0 truncate">{taskSourceAvailabilityNotice.label}</span>
</div>
) : null}
{taskSource === 'github' ? (
<div className="flex min-w-0 flex-wrap items-center gap-2">
{projectModeVisible ? (
@@ -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')
})
})
@@ -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[]