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:
Jinjing
2026-09-14 16:44:30 -07:00
committed by GitHub
parent 6cb5643241
commit 4bcdc67369
10 changed files with 515 additions and 67 deletions
@@ -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)
})
})
@@ -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>
+5 -2
View File
@@ -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",