mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Distinguish pane load failures from empty states (#20735)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(settings): tell a failed load apart from a genuinely empty pane * refactor: consolidate import types and simplify failure handling - Move filesystem import types to shared for renderer use - Add compactIpcErrorMessage for single-line error display - Consolidate entry failure toasts to single global slot - Simplify account tracking and discard retry logic * fix type * fix: clear stale state when pane loads fail Credential reads, account fetches, and skill scans can fail, leaving stale data on screen. This change clears previous state when a load fails, distinguishing load failures from genuinely empty results, and prevents stale controls from appearing after failed re-checks. Use readIpcErrorMessage for consistent error handling and track runtime targets to invalidate results from old targets. * fix(settings): show credential action when bitbucket status read fails When the credential-read operation fails, allow users to retry by showing "Add or replace credentials" button. Initialize the credentials dialog with the current (confirmed) connection state instead of stale data from a failed read, preventing outdated information from pre-populating the form.
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const rateLimits: Record<string, unknown> = { claude: null, codex: null }
|
||||
return { claudeList: vi.fn(), codexList: vi.fn(), rateLimits }
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
fetchSettings: async () => {},
|
||||
fetchRateLimits: async () => {},
|
||||
rateLimits: mocks.rateLimits
|
||||
})
|
||||
}))
|
||||
|
||||
import { UsageAccountsCard } from './UsageAccountsCard'
|
||||
|
||||
const EMPTY_ACCOUNTS = { accounts: [], activeAccountId: null }
|
||||
const UNKNOWN_TEXT = 'Account status unknown'
|
||||
const NOT_SET_UP_TEXT = 'Tracking not set up'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
async function renderCard(): Promise<void> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(<UsageAccountsCard />)
|
||||
})
|
||||
}
|
||||
|
||||
describe('UsageAccountsCard account-list failures', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.rateLimits = { claude: null, codex: null }
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
claudeAccounts: { list: mocks.claudeList, add: vi.fn() },
|
||||
codexAccounts: { list: mocks.codexList, add: vi.fn() }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('stops asserting "Tracking not set up" for a provider whose list never loaded', async () => {
|
||||
mocks.claudeList.mockRejectedValue(new Error('offline'))
|
||||
mocks.codexList.mockResolvedValue(EMPTY_ACCOUNTS)
|
||||
|
||||
await renderCard()
|
||||
|
||||
const pills = Array.from(container.querySelectorAll('span')).map((node) => node.textContent)
|
||||
expect(pills).toContain(UNKNOWN_TEXT)
|
||||
// Why: only the failing provider goes unknown — Codex genuinely answered "none".
|
||||
expect(pills).toContain(NOT_SET_UP_TEXT)
|
||||
})
|
||||
|
||||
it('keeps the real label when the list resolves empty', async () => {
|
||||
mocks.claudeList.mockResolvedValue(EMPTY_ACCOUNTS)
|
||||
mocks.codexList.mockResolvedValue(EMPTY_ACCOUNTS)
|
||||
|
||||
await renderCard()
|
||||
|
||||
expect(container.textContent).not.toContain(UNKNOWN_TEXT)
|
||||
expect(container.textContent).toContain(NOT_SET_UP_TEXT)
|
||||
})
|
||||
|
||||
it('prefers the observed connection when rate limits already prove tracking is on', async () => {
|
||||
mocks.claudeList.mockRejectedValue(new Error('offline'))
|
||||
mocks.codexList.mockResolvedValue(EMPTY_ACCOUNTS)
|
||||
mocks.rateLimits = { claude: { status: 'ok', session: null, weekly: null }, codex: null }
|
||||
|
||||
await renderCard()
|
||||
|
||||
expect(container.textContent).not.toContain(UNKNOWN_TEXT)
|
||||
expect(container.textContent).toContain('Connected · System default')
|
||||
})
|
||||
|
||||
it('does not claim tracking is unset while the account read is pending', async () => {
|
||||
let rejectClaude: (reason: Error) => void = () => {}
|
||||
mocks.claudeList.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectClaude = reject
|
||||
})
|
||||
)
|
||||
mocks.codexList.mockResolvedValue(EMPTY_ACCOUNTS)
|
||||
|
||||
await renderCard()
|
||||
|
||||
expect(container.textContent).toContain(UNKNOWN_TEXT)
|
||||
expect(container.textContent).toContain(NOT_SET_UP_TEXT)
|
||||
|
||||
await act(async () => {
|
||||
rejectClaude(new Error('offline'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain(UNKNOWN_TEXT)
|
||||
})
|
||||
})
|
||||
+9
-14
@@ -6,6 +6,7 @@ import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ClaudeIcon, OpenAIIcon } from '@/components/status-bar/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { readIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type {
|
||||
ClaudeRateLimitAccountsState,
|
||||
@@ -97,14 +98,8 @@ export function UsageAccountsCard(props: {
|
||||
const fetchRateLimits = useAppStore((s) => s.fetchRateLimits)
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
const [claudeAccounts, setClaudeAccounts] = useState<ClaudeRateLimitAccountsState>({
|
||||
accounts: [],
|
||||
activeAccountId: null
|
||||
})
|
||||
const [codexAccounts, setCodexAccounts] = useState<CodexRateLimitAccountsState>({
|
||||
accounts: [],
|
||||
activeAccountId: null
|
||||
})
|
||||
const [claudeAccounts, setClaudeAccounts] = useState<ClaudeRateLimitAccountsState>()
|
||||
const [codexAccounts, setCodexAccounts] = useState<CodexRateLimitAccountsState>()
|
||||
const [claudeAction, setClaudeAction] = useState<ConnectAction>('idle')
|
||||
const [codexAction, setCodexAction] = useState<ConnectAction>('idle')
|
||||
|
||||
@@ -121,7 +116,7 @@ export function UsageAccountsCard(props: {
|
||||
setClaudeAccounts(next)
|
||||
}
|
||||
} catch {
|
||||
// Silent — empty list is the right fallback for the inline pitch.
|
||||
// Leave the account state unknown.
|
||||
}
|
||||
})()
|
||||
void (async () => {
|
||||
@@ -131,7 +126,7 @@ export function UsageAccountsCard(props: {
|
||||
setCodexAccounts(next)
|
||||
}
|
||||
} catch {
|
||||
// Silent — same reason as above.
|
||||
// Leave the account state unknown.
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
@@ -140,11 +135,11 @@ export function UsageAccountsCard(props: {
|
||||
}, [fetchRateLimits])
|
||||
|
||||
const claudeConnection = getFeatureWallUsageProviderConnection({
|
||||
managedAccountCount: claudeAccounts.accounts.length,
|
||||
managedAccountCount: claudeAccounts?.accounts.length,
|
||||
provider: rateLimits.claude
|
||||
})
|
||||
const codexConnection = getFeatureWallUsageProviderConnection({
|
||||
managedAccountCount: codexAccounts.accounts.length,
|
||||
managedAccountCount: codexAccounts?.accounts.length,
|
||||
provider: rateLimits.codex
|
||||
})
|
||||
|
||||
@@ -178,7 +173,7 @@ export function UsageAccountsCard(props: {
|
||||
'Claude sign-in failed.'
|
||||
),
|
||||
{
|
||||
description: String((error as Error)?.message ?? error)
|
||||
description: readIpcErrorMessage(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -219,7 +214,7 @@ export function UsageAccountsCard(props: {
|
||||
'Codex sign-in failed.'
|
||||
),
|
||||
{
|
||||
description: String((error as Error)?.message ?? error)
|
||||
description: readIpcErrorMessage(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,24 @@ describe('feature wall usage tracking state', () => {
|
||||
).toEqual({ connected: false, label: 'Tracking not set up' })
|
||||
})
|
||||
|
||||
it('returns unknown when the account list has not loaded', () => {
|
||||
expect(
|
||||
getFeatureWallUsageProviderConnection({
|
||||
managedAccountCount: undefined,
|
||||
provider: null
|
||||
})
|
||||
).toEqual({ connected: false, label: 'Account status unknown' })
|
||||
})
|
||||
|
||||
it('prefers observed provider usage over an unknown account list', () => {
|
||||
expect(
|
||||
getFeatureWallUsageProviderConnection({
|
||||
managedAccountCount: undefined,
|
||||
provider: rateLimits()
|
||||
})
|
||||
).toEqual({ connected: true, label: 'Connected · System default' })
|
||||
})
|
||||
|
||||
it('marks the usage step complete from system-default provider data', () => {
|
||||
expect(
|
||||
hasFeatureWallUsageTracking({
|
||||
|
||||
@@ -19,16 +19,17 @@ export function hasFeatureWallProviderUsageTracking(provider: ProviderRateLimits
|
||||
}
|
||||
|
||||
export function getFeatureWallUsageProviderConnection(args: {
|
||||
managedAccountCount: number
|
||||
managedAccountCount: number | undefined
|
||||
provider: ProviderRateLimits | null
|
||||
}): FeatureWallUsageProviderConnection {
|
||||
if (args.managedAccountCount > 0) {
|
||||
const { managedAccountCount } = args
|
||||
if (managedAccountCount !== undefined && managedAccountCount > 0) {
|
||||
return {
|
||||
connected: true,
|
||||
label: translate(
|
||||
'auto.components.feature.wall.feature.wall.usage.tracking.00087eecb2',
|
||||
'Connected · {{value0}}',
|
||||
{ value0: args.managedAccountCount }
|
||||
{ value0: managedAccountCount }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +42,15 @@ export function getFeatureWallUsageProviderConnection(args: {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (managedAccountCount === undefined) {
|
||||
return {
|
||||
connected: false,
|
||||
label: translate(
|
||||
'auto.components.feature.wall.agents.orchestration.UsageAccountsCard.accountStatusUnknown',
|
||||
'Account status unknown'
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
connected: false,
|
||||
label: translate(
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
refresh: vi.fn(),
|
||||
status: vi.fn(),
|
||||
statuses: { bitbucketStatus: 'not-authenticated', bitbucketAccount: null }
|
||||
}))
|
||||
|
||||
vi.mock('./source-control-preflight-card-status', () => ({
|
||||
usePreflightCardStatuses: () => ({
|
||||
statuses: mocks.statuses,
|
||||
unavailable: false,
|
||||
refresh: mocks.refresh
|
||||
})
|
||||
}))
|
||||
vi.mock('./bitbucket-credentials-dialog', () => ({
|
||||
BitbucketCredentialsDialog: ({ open, initialEmail }: { open: boolean; initialEmail?: string }) =>
|
||||
open ? <div>Credential dialog open {initialEmail}</div> : null
|
||||
}))
|
||||
|
||||
import { BitbucketIntegrationCard } from './bitbucket-integration-card'
|
||||
|
||||
const LOAD_FAILED_TEXT = 'Could not check for a saved Bitbucket credential.'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
async function renderCard(): Promise<void> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(<BitbucketIntegrationCard />)
|
||||
})
|
||||
}
|
||||
|
||||
function recheckButton(): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent === 'Re-check'
|
||||
)
|
||||
if (!button) {
|
||||
throw new Error('Re-check button not rendered')
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
describe('BitbucketIntegrationCard credential-read failures', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.statuses = { bitbucketStatus: 'not-authenticated', bitbucketAccount: null }
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
bitbucket: { status: mocks.status, disconnect: vi.fn(async () => {}) },
|
||||
shell: { openUrl: vi.fn() }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('says the credential could not be read rather than rendering as "nothing stored"', async () => {
|
||||
mocks.status.mockRejectedValue(new Error('keychain locked'))
|
||||
|
||||
await renderCard()
|
||||
|
||||
expect(container.textContent).toContain(LOAD_FAILED_TEXT)
|
||||
expect(container.textContent).not.toContain('Connect')
|
||||
expect(container.textContent).toContain('Add or replace credentials')
|
||||
expect(container.textContent).not.toContain('credentials are configured')
|
||||
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll('button'))
|
||||
.find((button) => button.textContent === 'Add or replace credentials')
|
||||
?.click()
|
||||
})
|
||||
expect(container.textContent).toContain('Credential dialog open')
|
||||
})
|
||||
|
||||
it('does not claim a read failure when the status resolves', async () => {
|
||||
mocks.status.mockResolvedValue({ source: 'none', account: null })
|
||||
|
||||
await renderCard()
|
||||
|
||||
expect(container.textContent).not.toContain(LOAD_FAILED_TEXT)
|
||||
})
|
||||
|
||||
it('retries the failed credential read from Re-check, not just the preflight', async () => {
|
||||
mocks.status.mockRejectedValueOnce(new Error('keychain locked'))
|
||||
mocks.status.mockResolvedValueOnce({ source: 'none', account: null })
|
||||
|
||||
await renderCard()
|
||||
expect(container.textContent).toContain(LOAD_FAILED_TEXT)
|
||||
|
||||
await act(async () => {
|
||||
recheckButton().click()
|
||||
})
|
||||
|
||||
expect(mocks.status).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.refresh).toHaveBeenCalled()
|
||||
expect(container.textContent).not.toContain(LOAD_FAILED_TEXT)
|
||||
})
|
||||
|
||||
it('does not let an older failed read overwrite a newer successful re-check', async () => {
|
||||
let rejectInitial!: (error: Error) => void
|
||||
mocks.status
|
||||
.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectInitial = reject
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ source: 'none', account: null })
|
||||
|
||||
await renderCard()
|
||||
await act(async () => {
|
||||
recheckButton().click()
|
||||
})
|
||||
await act(async () => {
|
||||
rejectInitial(new Error('late keychain failure'))
|
||||
})
|
||||
|
||||
expect(container.textContent).not.toContain(LOAD_FAILED_TEXT)
|
||||
})
|
||||
|
||||
it('does not expose stale credential controls after a re-check fails', async () => {
|
||||
mocks.statuses = { bitbucketStatus: 'connected', bitbucketAccount: null }
|
||||
mocks.status
|
||||
.mockResolvedValueOnce({
|
||||
configured: true,
|
||||
source: 'stored',
|
||||
account: 'stale-account',
|
||||
authMode: 'token',
|
||||
email: 'stale@example.com',
|
||||
baseUrl: null
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('keychain locked'))
|
||||
|
||||
await renderCard()
|
||||
expect(container.textContent).toContain('stale-account')
|
||||
expect(container.textContent).toContain('Edit credentials')
|
||||
expect(container.querySelector('[aria-label="Disconnect Bitbucket"]')).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
recheckButton().click()
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain(LOAD_FAILED_TEXT)
|
||||
expect(container.textContent).not.toContain('stale-account')
|
||||
expect(container.textContent).not.toContain('Edit credentials')
|
||||
expect(container.textContent).toContain('Add or replace credentials')
|
||||
expect(container.querySelector('[aria-label="Disconnect Bitbucket"]')).toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll('button'))
|
||||
.find((button) => button.textContent === 'Add or replace credentials')
|
||||
?.click()
|
||||
})
|
||||
expect(container.textContent).toContain('Credential dialog open')
|
||||
expect(container.textContent).not.toContain('stale@example.com')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ExternalLink, GitPullRequestArrow, LoaderCircle, Unlink } from 'lucide-react'
|
||||
import type { BitbucketConnectionStatus } from '../../../../shared/bitbucket-credentials'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { readIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
|
||||
import { useIntegrationSubordinateRowClass } from './integration-card-presentation'
|
||||
import type { BitbucketStatus } from './integrations-pane-status'
|
||||
@@ -24,17 +25,24 @@ export function BitbucketIntegrationCard(): React.JSX.Element {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [disconnectError, setDisconnectError] = useState<string | null>(null)
|
||||
const [connectionLoadFailed, setConnectionLoadFailed] = useState(false)
|
||||
const connectionLoadGenerationRef = useRef(0)
|
||||
|
||||
// Reads plaintext metadata only — never the encrypted secret — so mounting the
|
||||
// pane cannot trigger a keychain prompt.
|
||||
const loadConnection = useCallback(async () => {
|
||||
const generation = ++connectionLoadGenerationRef.current
|
||||
try {
|
||||
const next = await window.api.bitbucket.status()
|
||||
if (mountedRef.current) {
|
||||
if (mountedRef.current && generation === connectionLoadGenerationRef.current) {
|
||||
setConnection(next)
|
||||
setConnectionLoadFailed(false)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: the preflight-driven parts of the card still render.
|
||||
// Why: without this the card renders exactly like "no credential stored" — say it is unknown.
|
||||
if (mountedRef.current && generation === connectionLoadGenerationRef.current) {
|
||||
setConnectionLoadFailed(true)
|
||||
}
|
||||
}
|
||||
}, [mountedRef])
|
||||
|
||||
@@ -42,14 +50,18 @@ export function BitbucketIntegrationCard(): React.JSX.Element {
|
||||
void loadConnection()
|
||||
}, [loadConnection])
|
||||
|
||||
const envManaged = connection?.source === 'environment'
|
||||
const storedCredential = connection?.source === 'stored'
|
||||
const account = connection?.account ?? statuses.bitbucketAccount
|
||||
const currentConnection = connectionLoadFailed ? null : connection
|
||||
const credentialStatusKnown = currentConnection !== null
|
||||
const envManaged = currentConnection?.source === 'environment'
|
||||
const storedCredential = currentConnection?.source === 'stored'
|
||||
const account = currentConnection?.account ?? statuses.bitbucketAccount
|
||||
// Only surface a base URL the user actually overrode; the default is noise.
|
||||
const baseUrlOverride =
|
||||
connection?.baseUrl && connection.baseUrl !== DEFAULT_API_BASE_URL ? connection.baseUrl : null
|
||||
const authModeLabel = connection?.authMode
|
||||
? connection.authMode === 'token'
|
||||
currentConnection?.baseUrl && currentConnection.baseUrl !== DEFAULT_API_BASE_URL
|
||||
? currentConnection.baseUrl
|
||||
: null
|
||||
const authModeLabel = currentConnection?.authMode
|
||||
? currentConnection.authMode === 'token'
|
||||
? translate(
|
||||
'auto.components.settings.bitbucket.integration.card.authModeToken',
|
||||
'Access token'
|
||||
@@ -61,7 +73,8 @@ export function BitbucketIntegrationCard(): React.JSX.Element {
|
||||
: null
|
||||
const credentialSummary = [authModeLabel, baseUrlOverride].filter(Boolean).join(' · ')
|
||||
|
||||
const handleConnected = (): void => {
|
||||
// A fresh connection and Re-check both refresh preflight and credential state.
|
||||
const reloadCardState = (): void => {
|
||||
void loadConnection()
|
||||
refresh()
|
||||
}
|
||||
@@ -76,20 +89,18 @@ export function BitbucketIntegrationCard(): React.JSX.Element {
|
||||
// Unhandled, the card silently re-renders as still connected.
|
||||
if (mountedRef.current) {
|
||||
setDisconnectError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.bitbucket.integration.card.disconnectFailed',
|
||||
'Could not remove the saved Bitbucket credential.'
|
||||
)
|
||||
readIpcErrorMessage(error) ??
|
||||
translate(
|
||||
'auto.components.settings.bitbucket.integration.card.disconnectFailed',
|
||||
'Could not remove the saved Bitbucket credential.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setDisconnecting(false)
|
||||
}
|
||||
void loadConnection()
|
||||
refresh()
|
||||
reloadCardState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,18 +129,26 @@ export function BitbucketIntegrationCard(): React.JSX.Element {
|
||||
statusTone={connected ? 'connected' : 'attention'}
|
||||
statusLabel={tokenProviderStatusLabel({ configured: connected, status })}
|
||||
actions={
|
||||
status !== 'checking' && !envManaged ? (
|
||||
status !== 'checking' && !envManaged && (credentialStatusKnown || connectionLoadFailed) ? (
|
||||
<Button
|
||||
variant={storedCredential ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
{storedCredential
|
||||
{connectionLoadFailed
|
||||
? translate(
|
||||
'auto.components.settings.bitbucket.integration.card.edit',
|
||||
'Edit credentials'
|
||||
'auto.components.settings.bitbucket.integration.card.replaceCredentials',
|
||||
'Add or replace credentials'
|
||||
)
|
||||
: translate('auto.components.settings.bitbucket.integration.card.connect', 'Connect')}
|
||||
: storedCredential
|
||||
? translate(
|
||||
'auto.components.settings.bitbucket.integration.card.edit',
|
||||
'Edit credentials'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.bitbucket.integration.card.connect',
|
||||
'Connect'
|
||||
)}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
@@ -170,11 +189,21 @@ export function BitbucketIntegrationCard(): React.JSX.Element {
|
||||
</div>
|
||||
) : null}
|
||||
{disconnectError ? <p className="text-xs text-destructive">{disconnectError}</p> : null}
|
||||
<BitbucketCardNote
|
||||
envManaged={envManaged}
|
||||
status={status}
|
||||
storedCredential={storedCredential}
|
||||
/>
|
||||
{connectionLoadFailed ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{translate(
|
||||
'auto.components.settings.bitbucket.integration.card.statusLoadFailed',
|
||||
'Could not check for a saved Bitbucket credential.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{credentialStatusKnown ? (
|
||||
<BitbucketCardNote
|
||||
envManaged={envManaged}
|
||||
status={status}
|
||||
storedCredential={storedCredential}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
{!connected ? (
|
||||
<Button
|
||||
@@ -189,7 +218,7 @@ export function BitbucketIntegrationCard(): React.JSX.Element {
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onClick={refresh}>
|
||||
<Button variant="ghost" size="sm" onClick={reloadCardState}>
|
||||
{translate(
|
||||
'auto.components.settings.token.source.control.integration.cards.793a06e899',
|
||||
'Re-check'
|
||||
@@ -202,11 +231,11 @@ export function BitbucketIntegrationCard(): React.JSX.Element {
|
||||
<BitbucketCredentialsDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
initialAuthMode={connection?.authMode}
|
||||
initialEmail={connection?.email}
|
||||
initialBaseUrl={connection?.baseUrl}
|
||||
initialAuthMode={currentConnection?.authMode}
|
||||
initialEmail={currentConnection?.email}
|
||||
initialBaseUrl={currentConnection?.baseUrl}
|
||||
environmentManaged={envManaged}
|
||||
onConnected={handleConnected}
|
||||
onConnected={reloadCardState}
|
||||
/>
|
||||
</IntegrationCardShell>
|
||||
)
|
||||
|
||||
@@ -264,6 +264,34 @@ describe('SkillsPage', () => {
|
||||
expect(renderedSkillNames()).not.toContain('local-only')
|
||||
})
|
||||
|
||||
it("does not show one runtime's skills when the next runtime scan fails", async () => {
|
||||
const discover = vi.fn().mockResolvedValue(discoveryResult(['local-only']))
|
||||
const call = vi.fn(async (args: { method: string; selector?: string }) => {
|
||||
const compatibilityResponse = createCompatibleRuntimeStatusResponseIfNeeded(args)
|
||||
if (compatibilityResponse) {
|
||||
return compatibilityResponse
|
||||
}
|
||||
throw new Error('remote unavailable')
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: skillsApi(discover), runtimeEnvironments: { call } }
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await renderPage()
|
||||
await flushMicrotasks()
|
||||
expect(renderedSkillNames()).toEqual(['local-only'])
|
||||
|
||||
await act(async () => {
|
||||
setRuntimeOwner('env-1')
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(container?.textContent).toContain('Could not scan skills')
|
||||
expect(renderedSkillNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps scanning rather than listing client skills before the owner is known', async () => {
|
||||
const discover = vi.fn().mockResolvedValue(discoveryResult(['local-only']))
|
||||
const call = vi.fn()
|
||||
@@ -451,4 +479,70 @@ describe('SkillsPage', () => {
|
||||
expect(container?.textContent).toContain('0 selected')
|
||||
expect(renderedSkillNames()).toEqual(['beta'])
|
||||
})
|
||||
it('distinguishes a failed scan from empty skill folders', async () => {
|
||||
const discover = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error(
|
||||
"Error invoking remote method 'skills:discover': Error: EACCES: permission denied\nSSH host unavailable"
|
||||
)
|
||||
)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await renderPage()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(container?.textContent).toContain('Could not scan skills')
|
||||
expect(container?.textContent).toContain('EACCES: permission denied')
|
||||
expect(container?.textContent).toContain('SSH host unavailable')
|
||||
expect(container?.textContent).not.toContain('Error invoking remote method')
|
||||
// Why: nothing was scanned, so "the scanned skill folders are empty" would be a claim we cannot make.
|
||||
expect(container?.textContent).not.toContain('No skills found')
|
||||
})
|
||||
|
||||
it('retries the failed scan from the error band and clears it on success', async () => {
|
||||
const discover = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('EACCES: permission denied'))
|
||||
.mockResolvedValueOnce(discoveryResult(['alpha']))
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await renderPage()
|
||||
await flushMicrotasks()
|
||||
await act(async () => fireEvent.click(buttonNamed('Retry')))
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(container?.textContent).not.toContain('Could not scan skills')
|
||||
expect(renderedSkillNames()).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('keeps a previously confirmed empty result visible when a refresh fails', async () => {
|
||||
const discover = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(discoveryResult([]))
|
||||
.mockRejectedValueOnce(new Error('host unavailable'))
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await renderPage()
|
||||
await flushMicrotasks()
|
||||
expect(container?.textContent).toContain('No skills found')
|
||||
|
||||
await act(async () => fireEvent.click(buttonNamed('Refresh')))
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(container?.textContent).toContain('Could not scan skills')
|
||||
expect(container?.textContent).toContain('No skills found')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Share2, Trash2 } from 'lucide-react'
|
||||
import { readIpcErrorDetail } from '@/lib/ipc-error'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { discoverSkillsForRuntimeTarget } from '@/runtime/runtime-skills-client'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { useActiveSkillDiscoveryRuntimeTarget } from '@/hooks/use-active-skill-discovery-runtime-target'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
|
||||
@@ -58,6 +60,12 @@ const NO_FILTERS: SkillsFilterState = {
|
||||
agent: 'all'
|
||||
}
|
||||
|
||||
type SkillScanState = {
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
result: SkillDiscoveryResult | null
|
||||
error: { detail?: string } | null
|
||||
}
|
||||
|
||||
export default function SkillsPage(): React.JSX.Element {
|
||||
const closeSkillsPage = useAppStore((s) => s.closeSkillsPage)
|
||||
const pendingSkillShareId = useAppStore((s) => s.pendingSkillShareId)
|
||||
@@ -66,9 +74,12 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
const clearPendingSkillsSharedView = useAppStore((s) => s.clearPendingSkillsSharedView)
|
||||
const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
|
||||
const hostLabel = useSkillDiscoveryHostLabel(runtimeTarget)
|
||||
const [result, setResult] = useState<SkillDiscoveryResult | null>(null)
|
||||
const [scanState, setScanState] = useState<SkillScanState | null>(null)
|
||||
// Target identity changes on host switches and same-ID re-pairs.
|
||||
const currentScan = scanState?.runtimeTarget === runtimeTarget ? scanState : null
|
||||
const result = currentScan?.result ?? null
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [scanError, setScanError] = useState<string | null>(null)
|
||||
const scanError = currentScan?.error ?? null
|
||||
const [shareSkills, setShareSkills] = useState<DiscoveredSkill[]>([])
|
||||
const [selectionMode, setSelectionMode] = useState<'share' | 'delete' | null>(null)
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(() => new Set())
|
||||
@@ -108,8 +119,7 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
)
|
||||
const local = runtimeTarget.kind === 'local'
|
||||
if (isCurrentScan()) {
|
||||
setResult(nextResult)
|
||||
setScanError(null)
|
||||
setScanState({ runtimeTarget, result: nextResult, error: null })
|
||||
setSelectedSkillIds((current) =>
|
||||
selectionModeRef.current === 'delete'
|
||||
? retainedDeletableSkillSelection(current, nextResult.skills)
|
||||
@@ -121,9 +131,11 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
if (isCurrentScan()) {
|
||||
// Why: a failed scan needs to stay on screen with a retry — a toast
|
||||
// disappears before the user can act on it.
|
||||
setScanError(
|
||||
translate('auto.components.skills.SkillsPage.ea72d6185b', 'Could not scan skills')
|
||||
)
|
||||
setScanState((current) => ({
|
||||
runtimeTarget,
|
||||
result: current?.runtimeTarget === runtimeTarget ? current.result : null,
|
||||
error: { detail: readIpcErrorDetail(error) }
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentScan()) {
|
||||
@@ -310,7 +322,7 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
/>
|
||||
{scanError ? (
|
||||
<SkillsScanErrorBand
|
||||
message={scanError}
|
||||
detail={scanError.detail}
|
||||
disabled={loading}
|
||||
onRetry={() => {
|
||||
deleteFlow.reprobe()
|
||||
@@ -359,7 +371,7 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
/>
|
||||
) : skills.length > 0 ? (
|
||||
<SkillsNoMatchesState onClearFilters={() => setFilters(NO_FILTERS)} />
|
||||
) : (
|
||||
) : result ? (
|
||||
<SkillsEmptyState
|
||||
onRefresh={() => {
|
||||
deleteFlow.reprobe()
|
||||
@@ -367,7 +379,7 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
}}
|
||||
onInstallFromLink={openInstallDialog}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -84,11 +84,11 @@ export function SkillsEmptyState({
|
||||
}
|
||||
|
||||
export function SkillsScanErrorBand({
|
||||
message,
|
||||
detail,
|
||||
disabled,
|
||||
onRetry
|
||||
}: {
|
||||
message: string
|
||||
detail?: string
|
||||
disabled: boolean
|
||||
onRetry: () => void
|
||||
}): React.JSX.Element {
|
||||
@@ -97,9 +97,18 @@ export function SkillsScanErrorBand({
|
||||
<div
|
||||
className={cn(SKILLS_PAGE_COLUMN, 'flex flex-wrap items-center justify-between gap-3 py-2')}
|
||||
>
|
||||
<p className="min-w-0 flex-1 text-xs text-destructive" role="alert">
|
||||
{message}
|
||||
</p>
|
||||
{/* Announce the detail with the headline. */}
|
||||
<div className="min-w-0 flex-1" role="alert">
|
||||
<p className={cn('text-xs text-destructive', detail && 'font-medium')}>
|
||||
{translate('auto.components.skills.SkillsPage.ea72d6185b', 'Could not scan skills')}
|
||||
</p>
|
||||
{detail ? (
|
||||
// Preserve multi-line git and SSH errors.
|
||||
<p className="whitespace-pre-line break-words text-xs leading-snug text-destructive">
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="xs" disabled={disabled} onClick={onRetry}>
|
||||
{translate('auto.components.skills.SkillsPage.retry', 'Retry')}
|
||||
</Button>
|
||||
|
||||
@@ -11816,7 +11816,9 @@
|
||||
"storedAuthFailed": "The saved Bitbucket credential could not authenticate. Edit it, or check that the token still has pull request access.",
|
||||
"storedCredential": "Saved in Orca on this machine. ORCA_BITBUCKET_* environment variables take precedence when set.",
|
||||
"notConfigured": "Connect a Bitbucket Cloud account with an Atlassian API token or an access token. ORCA_BITBUCKET_* environment variables work too and take precedence.",
|
||||
"disconnectFailed": "Could not remove the saved Bitbucket credential."
|
||||
"disconnectFailed": "Could not remove the saved Bitbucket credential.",
|
||||
"statusLoadFailed": "Could not check for a saved Bitbucket credential.",
|
||||
"replaceCredentials": "Add or replace credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -14309,7 +14311,8 @@
|
||||
"4e71d72912": "Claude sign-in failed.",
|
||||
"9ddeb558f9": "Claude account added.",
|
||||
"29d0653961": "Sign in",
|
||||
"945865332e": "Signing in"
|
||||
"945865332e": "Signing in",
|
||||
"accountStatusUnknown": "Account status unknown"
|
||||
},
|
||||
"UsagePage": {
|
||||
"64265cb295": "29% used 5h",
|
||||
|
||||
Reference in New Issue
Block a user