mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Clarify unavailable task source hosts
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user