diff --git a/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx index 75da65cd885..d362fc1218b 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx @@ -1,25 +1,30 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -import { LinearAgentSkillGuide } from './LinearAgentSkillGuide' +import { LinearAgentSkillGuide, type LinearSetupReadiness } from './LinearAgentSkillGuide' -const baseStatus = { +const baseReadiness: LinearSetupReadiness = { connected: true, - connectionChecking: false, + checking: false, skillInstalled: false, skillChecking: false, - visibleInTasks: true + skillUnverifiable: false, + visible: true +} + +function renderGuide(readiness: Partial): string { + return renderToStaticMarkup( + Skill install panel} + /> + ) } describe('LinearAgentSkillGuide', () => { it('renders the setup checklist with an inlined skill panel', () => { - const markup = renderToStaticMarkup( - Skill install panel} - /> - ) + const markup = renderGuide({}) expect(markup).toContain('Setup checklist') expect(markup).toContain('2 of 3 ready') @@ -32,34 +37,11 @@ describe('LinearAgentSkillGuide', () => { }) it('marks the checklist complete when every step is done', () => { - const markup = renderToStaticMarkup( - Skill panel} - /> - ) - - expect(markup).toContain('All set') + expect(renderGuide({ skillInstalled: true })).toContain('All set') }) it('keeps durable progress while a skill recheck is in flight', () => { - const markup = renderToStaticMarkup( - Skill panel} - /> - ) + const markup = renderGuide({ skillInstalled: true, skillChecking: true }) expect(markup).toContain('Checking…') expect(markup).not.toContain('2 of 3 ready') @@ -67,20 +49,53 @@ describe('LinearAgentSkillGuide', () => { }) it('keeps durable progress while a connection check is in flight', () => { - const markup = renderToStaticMarkup( - Skill panel} - /> - ) + const markup = renderGuide({ skillInstalled: true, checking: true }) expect(markup).toContain('Checking…') expect(markup).not.toContain('2 of 3 ready') }) + + // The reported bug: a scan that could not vouch for "not installed" was counted + // as a step the user had left undone. + it('reports an unverifiable skill scan as unknown instead of an unfinished step', () => { + const markup = renderGuide({ skillUnverifiable: true }) + + expect(markup).toContain('Cannot verify') + expect(markup).toContain('2/3') + expect(markup).toContain('bg-amber-500') + expect(markup).not.toContain('2 of 3 ready') + expect(markup).not.toContain('All set') + }) + + it('still claims nothing while a rescan of an unverifiable step runs', () => { + const markup = renderGuide({ skillUnverifiable: true, skillChecking: true }) + + expect(markup).toContain('Checking…') + expect(markup).not.toContain('Cannot verify') + }) + + it('lets a found skill outrank a stale unverifiable flag', () => { + const markup = renderGuide({ skillInstalled: true, skillUnverifiable: true }) + + expect(markup).toContain('All set') + expect(markup).not.toContain('Cannot verify') + }) + + // The unknown-skill label is only the headline when the skill is the sole open + // question; a plainly unfinished step must still read as the count. + it('keeps the confirmed count when the unfinished step is the connection', () => { + const markup = renderGuide({ connected: false, skillUnverifiable: true }) + + expect(markup).toContain('1 of 3 ready') + expect(markup).not.toContain('Cannot verify') + }) + + it('does not headline an unknown skill over an unfinished visibility step', () => { + const markup = renderGuide({ visible: false, skillUnverifiable: true }) + + expect(markup).toContain('1 of 3 ready') + expect(markup).not.toContain('Cannot verify') + // Hiding Linear is deliberate, so the shared table keeps this pill neutral. + expect(markup).not.toContain('bg-amber-500') + }) }) diff --git a/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx b/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx index 0bbcc77ec5f..ca3b6a5f165 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx @@ -1,19 +1,27 @@ import type { ReactNode } from 'react' -import { Check, Circle } from 'lucide-react' +import { Check, Circle, TriangleAlert } from 'lucide-react' import { Button } from '@/components/ui/button' -import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { + IntegrationStatusPill, + type IntegrationStatusTone +} from '@/components/integration-status-pill' +import { + TASK_PROVIDER_SETUP_STATUS_TONE, + getTaskProviderCompletedSteps, + getTaskProviderSetupStatus, + type TaskProviderReadiness +} from './task-source-setup-state' import { translate } from '@/i18n/i18n' -export type LinearSetupStepStatus = { - connected: boolean - connectionChecking: boolean +/** The guide renders the skill row, so unlike other providers those facts are required. */ +export type LinearSetupReadiness = TaskProviderReadiness & { skillInstalled: boolean skillChecking: boolean - visibleInTasks: boolean + skillUnverifiable: boolean } type LinearAgentSkillGuideProps = { - status: LinearSetupStepStatus + readiness: LinearSetupReadiness onOpenTaskSources: () => void onManageLinearAccess: () => void // Why: skill install/update lives once under step 2 so the page does not @@ -23,10 +31,12 @@ type LinearAgentSkillGuideProps = { function SetupStatusIcon({ done, - checking + checking, + unverifiable }: { done: boolean checking: boolean + unverifiable?: boolean }): React.JSX.Element { // Keep a fixed size-5 slot so checking/done/pending never shift the column. if (checking) { @@ -36,6 +46,15 @@ function SetupStatusIcon({ ) } + // Why above `done`: an unvouched-for scan says nothing about the step either + // way, and painting it as pending is the claim this checklist got wrong. + if (unverifiable) { + return ( + + + + ) + } if (done) { return ( @@ -50,21 +69,64 @@ function SetupStatusIcon({ ) } +type LinearSetupPill = { tone: IntegrationStatusTone; label: string; showCount: boolean } + +function getLinearSetupPill(readiness: LinearSetupReadiness): LinearSetupPill { + const { completed, total } = getTaskProviderCompletedSteps(readiness) + // Why: route through the card's status so the two Linear surfaces share one + // precedence. Reading `skillUnverifiable` directly here headlined "Cannot verify" + // over a step the user had plainly not done (or before they had even connected). + const status = getTaskProviderSetupStatus(readiness) + // Tone is the shared table's call, not this surface's; only the copy differs. + const tone = TASK_PROVIDER_SETUP_STATUS_TONE[status] + if (status === 'checking') { + return { + tone, + label: translate('auto.components.settings.LinearAgentSkillGuide.setupChecking', 'Checking…'), + showCount: false + } + } + if (status === 'ready') { + return { + tone, + label: translate('auto.components.settings.LinearAgentSkillGuide.setupReady', 'All set'), + showCount: false + } + } + // Why: a scan that cannot vouch for "not installed" must not be counted against + // the user, so the label reports what was confirmed instead of asserting a failure. + if (status === 'skill-unverified') { + return { + tone, + label: translate( + 'auto.components.settings.LinearAgentSkillGuide.setupUnverified', + 'Cannot verify' + ), + showCount: true + } + } + return { + tone, + label: translate( + 'auto.components.settings.LinearAgentSkillGuide.setupProgress', + '{{done}} of {{total}} ready', + { done: completed, total } + ), + showCount: false + } +} + // Connect, skill, and Tasks visibility in one checklist — skill UI is inlined. export function LinearAgentSkillGuide({ - status, + readiness, onOpenTaskSources, onManageLinearAccess, skillPanel }: LinearAgentSkillGuideProps): React.JSX.Element { - // Count durable outcomes even while a recheck runs so the pill does not flash - // from "All set" down to "2 of 3 ready" during skill/connection scans. - const checking = status.connectionChecking || status.skillChecking - const completed = [status.connected, status.skillInstalled, status.visibleInTasks].filter( - Boolean - ).length - const total = 3 - const allReady = completed === total && !checking + // Share the Task Sources card's arithmetic so the two Linear setup surfaces + // cannot disagree about the same three facts; the copy stays count-based here. + const pill = getLinearSetupPill(readiness) + const { completed, total } = getTaskProviderCompletedSteps(readiness) return (
@@ -83,23 +145,22 @@ export function LinearAgentSkillGuide({ )}

- - {checking - ? translate('auto.components.settings.LinearAgentSkillGuide.setupChecking', 'Checking…') - : allReady - ? translate('auto.components.settings.LinearAgentSkillGuide.setupReady', 'All set') - : translate( - 'auto.components.settings.LinearAgentSkillGuide.setupProgress', - '{{done}} of {{total}} ready', - { done: completed, total } - )} - + + {pill.label} + {pill.showCount ? ( + // Mirrors the Task Sources card so the confirmed count survives a label + // that no longer carries it. + + {`${completed}/${total}`} + + ) : null} +
- +

@@ -118,11 +179,11 @@ export function LinearAgentSkillGuide({ ') expect(markup).not.toContain('>Hide') }) + + it('labels an unverifiable skill scan as unknown while keeping the confirmed count', () => { + const markup = renderToStaticMarkup( + } + name="Linear" + description="Linear setup" + readiness={{ ...readiness, connected: true, skillUnverifiable: true }} + visible + canHide + defaultExpanded={false} + onToggleVisible={vi.fn()} + /> + ) + + expect(markup).toContain('Cannot verify') + expect(markup).toContain('2/3') + expect(markup).not.toContain('Skill required') + }) }) diff --git a/src/renderer/src/components/settings/TaskSourceProviderCard.tsx b/src/renderer/src/components/settings/TaskSourceProviderCard.tsx index 56e77fa5931..fa77b71c699 100644 --- a/src/renderer/src/components/settings/TaskSourceProviderCard.tsx +++ b/src/renderer/src/components/settings/TaskSourceProviderCard.tsx @@ -45,6 +45,11 @@ function getSetupStatusLabel(status: TaskProviderSetupStatus): string { 'auto.components.settings.TaskSourceProviderCard.statusSkillRequired', 'Skill required' ) + case 'skill-unverified': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusUnverified', + 'Cannot verify' + ) case 'unavailable': return translate( 'auto.components.settings.TaskSourceProviderCard.statusUnavailable', diff --git a/src/renderer/src/components/settings/task-source-setup-state.test.ts b/src/renderer/src/components/settings/task-source-setup-state.test.ts index 6eec46a34db..40bccf9f73e 100644 --- a/src/renderer/src/components/settings/task-source-setup-state.test.ts +++ b/src/renderer/src/components/settings/task-source-setup-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { TaskProvider } from '../../../../shared/task-providers' import { + TASK_PROVIDER_SETUP_STATUS_TONE, getAutoExpandedTaskProvider, getIncompleteVisibleTaskProviders, getStalledVisibleTaskProviders, @@ -84,6 +85,46 @@ describe('task-source-setup-state', () => { expect(isTaskProviderReady({ connected: true, checking: true, visible: true })).toBe(false) }) + // A skill scan that could not vouch for "not installed" is not a step the user + // left undone, so it must not read as `skill-required`. + it('reports an unverifiable skill scan as unknown rather than as a missing step', () => { + const unverifiable = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + skillUnverifiable: true, + visible: true + } + + expect(getTaskProviderSetupStatus(unverifiable)).toBe('skill-unverified') + expect(TASK_PROVIDER_SETUP_STATUS_TONE['skill-unverified']).toBe('attention') + expect(isTaskProviderReady(unverifiable)).toBe(false) + // The count reports confirmed steps, so it is unchanged by the unknown. + expect(getTaskProviderCompletedSteps(unverifiable)).toEqual({ completed: 2, total: 3 }) + }) + + it('keeps an in-flight check and an unconnected provider ahead of an unverifiable scan', () => { + expect( + getTaskProviderSetupStatus({ + connected: true, + checking: true, + skillInstalled: false, + skillUnverifiable: true, + visible: true + }) + ).toBe('checking') + expect( + getTaskProviderSetupStatus({ + connected: false, + checking: false, + skillInstalled: false, + skillUnverifiable: true, + visible: true + }) + ).toBe('connect-required') + }) + it('reports the first unmet step as the status', () => { expect(getTaskProviderSetupStatus({ connected: false, checking: true, visible: true })).toBe( 'checking' diff --git a/src/renderer/src/components/settings/task-source-setup-state.ts b/src/renderer/src/components/settings/task-source-setup-state.ts index 628de69dbf0..bc6babe07ea 100644 --- a/src/renderer/src/components/settings/task-source-setup-state.ts +++ b/src/renderer/src/components/settings/task-source-setup-state.ts @@ -8,6 +8,8 @@ export type TaskProviderReadiness = { /** Linear only — agent skill install. Other providers leave this undefined. */ skillInstalled?: boolean skillChecking?: boolean + /** The scan could not vouch for `skillInstalled: false`: an unread root, or an error before any answer. */ + skillUnverifiable?: boolean visible: boolean } @@ -16,6 +18,7 @@ export type TaskProviderSetupStatus = | 'ready' | 'connect-required' | 'skill-required' + | 'skill-unverified' | 'unavailable' | 'hidden' | 'incomplete' @@ -30,6 +33,7 @@ export const TASK_PROVIDER_SETUP_STATUS_TONE: Record< hidden: 'neutral', 'connect-required': 'attention', 'skill-required': 'attention', + 'skill-unverified': 'attention', unavailable: 'attention', incomplete: 'attention' } @@ -83,6 +87,10 @@ export function getTaskProviderSetupStatus( if (!readiness.connected) { return 'connect-required' } + // Why before `skill-required`: that status offers Install, which reinstalls a skill that may be present. + if (readiness.skillUnverifiable) { + return 'skill-unverified' + } if (readiness.skillInstalled === false) { return 'skill-required' } diff --git a/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts index 46c7ea2b932..f3a32534fe4 100644 --- a/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts +++ b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts @@ -30,6 +30,8 @@ export function useLinearAgentSkillSetup(): { // Status surfaces (step badges, checklist pills) read this so a focus-triggered // rescan does not flip a known result back to "checking". skillChecking: boolean + /** The scan could not vouch for "not installed", so no surface may claim it. */ + skillUnverifiable: boolean installDisabled: boolean error: string | null terminalShellOverride: string | undefined @@ -44,6 +46,7 @@ export function useLinearAgentSkillSetup(): { installed: skillInstalled, loading: skillLoading, settled: skillSettled, + installedUnverifiable: skillUnverifiable, error: skillError, skills: linearSkills, refresh: refreshSkill @@ -98,6 +101,7 @@ export function useLinearAgentSkillSetup(): { skillInstalled, skillLoading, skillChecking: skillLoading && !skillSettled, + skillUnverifiable, installDisabled, error: activeSkillRuntime.installDisabledReason ?? skillError, terminalShellOverride: activeSkillRuntime.terminalShellOverride, diff --git a/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx b/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx index d00a358996f..ac1df506058 100644 --- a/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx +++ b/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx @@ -13,8 +13,10 @@ const mocks = vi.hoisted(() => ({ installed: false, loading: false, settled: true, + installedUnverifiable: false, error: null, skills: [], + sources: [], refresh: vi.fn() } })) @@ -91,8 +93,10 @@ beforeEach(() => { installed: true, loading: false, settled: true, + installedUnverifiable: false, error: null, skills: [], + sources: [], refresh: vi.fn() } }) @@ -169,4 +173,12 @@ describe('useTaskSourceProviderReadiness', () => { await renderProbe(['github', 'linear', 'jira']) expect(latest?.jira.visible).toBe(true) }) + + it('carries an unverifiable skill scan through to Linear readiness', async () => { + mocks.skill = { ...mocks.skill, installed: false, installedUnverifiable: true } + await renderProbe() + + expect(latest?.linear.skillInstalled).toBe(false) + expect(latest?.linear.skillUnverifiable).toBe(true) + }) }) diff --git a/src/renderer/src/components/settings/use-task-source-provider-readiness.ts b/src/renderer/src/components/settings/use-task-source-provider-readiness.ts index 6b9fa6171ba..d669248eb4d 100644 --- a/src/renderer/src/components/settings/use-task-source-provider-readiness.ts +++ b/src/renderer/src/components/settings/use-task-source-provider-readiness.ts @@ -36,7 +36,8 @@ export function useTaskSourceProviderReadiness( const { installed: linearSkillInstalled, loading: linearSkillLoading, - settled: linearSkillSettled + settled: linearSkillSettled, + installedUnverifiable: linearSkillUnverifiable } = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS @@ -83,6 +84,7 @@ export function useTaskSourceProviderReadiness( checking: linearChecking, skillInstalled: linearSkillInstalled, skillChecking: linearSkillLoading && !linearSkillSettled, + skillUnverifiable: linearSkillUnverifiable, visible: visible.has('linear') }, jira: { @@ -101,6 +103,7 @@ export function useTaskSourceProviderReadiness( linearSkillInstalled, linearSkillLoading, linearSkillSettled, + linearSkillUnverifiable, reviewChecking, reviewUnavailable, visibleProvidersKey diff --git a/src/renderer/src/hooks/installed-agent-skill-verdict.test.ts b/src/renderer/src/hooks/installed-agent-skill-verdict.test.ts new file mode 100644 index 00000000000..93956f54220 --- /dev/null +++ b/src/renderer/src/hooks/installed-agent-skill-verdict.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import type { SkillDiscoverySource, SkillSourceKind } from '../../../shared/skills' +import { GLOBAL_AGENT_SKILL_SOURCE_KINDS } from './useInstalledAgentSkills' +import { + getInstalledAgentSkillVerdict, + hasUnreadableAgentSkillSource, + type InstalledAgentSkillScan +} from './installed-agent-skill-verdict' + +function source( + sourceKind: SkillSourceKind, + skippedReason?: SkillDiscoverySource['skippedReason'] +): SkillDiscoverySource { + return { + id: `${sourceKind}-root`, + label: sourceKind, + path: `/roots/${sourceKind}`, + sourceKind, + providers: ['claude'], + owner: null, + // An unread root reports `exists`: the host could not prove otherwise. + exists: true, + ...(skippedReason ? { skippedReason } : {}) + } +} + +function scan(overrides: Partial = {}): InstalledAgentSkillScan { + return { + enabled: true, + installed: false, + settled: true, + error: null, + sources: [], + sourceKinds: ['home'], + ...overrides + } +} + +const unverifiable = (overrides: Partial): boolean => + getInstalledAgentSkillVerdict(scan(overrides)).installedUnverifiable +const error = (overrides: Partial): string | null => + getInstalledAgentSkillVerdict(scan(overrides)).error + +describe('hasUnreadableAgentSkillSource', () => { + it('flags a root that did not answer even though it reports as present', () => { + expect(hasUnreadableAgentSkillSource([source('home', 'unavailable')])).toBe(true) + }) + + it('ignores roots that were scanned or are genuinely absent', () => { + expect( + hasUnreadableAgentSkillSource([ + source('home'), + { ...source('home'), id: 'gone', exists: false, skippedReason: 'missing' } + ]) + ).toBe(false) + }) + + it('ignores an unread root outside the scopes the caller asked about', () => { + expect( + hasUnreadableAgentSkillSource( + [source('repo', 'unavailable')], + GLOBAL_AGENT_SKILL_SOURCE_KINDS + ) + ).toBe(false) + }) +}) + +describe('getInstalledAgentSkillVerdict', () => { + it('treats a complete scan that found nothing as proof of absence', () => { + expect(unverifiable({ sources: [source('home')] })).toBe(false) + }) + + it('cannot vouch for a negative when a root this query cares about did not answer', () => { + expect(unverifiable({ sources: [source('home', 'unavailable')] })).toBe(true) + }) + + it('ignores an unreadable root outside the queried source kinds', () => { + expect(unverifiable({ sources: [source('repo', 'unavailable')] })).toBe(false) + }) + + // The reported bug: `sources` is empty until a result lands, so a scan that + // errored before answering is invisible to the unreadable-root check. + it('cannot vouch for a negative when the scan errored before ever answering', () => { + expect(unverifiable({ settled: false, error: 'scan failed' })).toBe(true) + }) + + it('keeps an answer it already holds when a later refresh fails', () => { + expect(unverifiable({ settled: true, error: 'scan failed' })).toBe(false) + }) + + it('stays silent while a first scan is still pending with no error', () => { + expect(unverifiable({ settled: false })).toBe(false) + }) + + it('takes finding the skill as proof, whatever else failed', () => { + expect(unverifiable({ installed: true, settled: false, error: 'scan failed' })).toBe(false) + }) + + it('says nothing about a query that is switched off', () => { + expect(unverifiable({ enabled: false, sources: [source('home', 'unavailable')] })).toBe(false) + }) + + it("prefers the scan's own failure over the advisory", () => { + expect(error({ settled: false, error: 'scan failed' })).toBe('scan failed') + }) + + it('advises when an unreadable root is the only reason the answer is empty', () => { + expect(error({ sources: [source('home', 'unavailable')] })).toContain('did not respond') + }) + + it('stays quiet for a trustworthy negative', () => { + expect(error({ sources: [source('home')] })).toBeNull() + }) +}) diff --git a/src/renderer/src/hooks/installed-agent-skill-verdict.ts b/src/renderer/src/hooks/installed-agent-skill-verdict.ts new file mode 100644 index 00000000000..025781c8644 --- /dev/null +++ b/src/renderer/src/hooks/installed-agent-skill-verdict.ts @@ -0,0 +1,65 @@ +import type { SkillDiscoverySource, SkillSourceKind } from '../../../shared/skills' +import { translate } from '@/i18n/i18n' + +/** + * True when a root this query cares about did not answer, so its skills are + * unknown rather than absent. The host serves such a root's last answer, but a + * root that has never answered has none to serve, and a bare "Not installed" + * there offers Install for a skill that may already be present. + */ +export function hasUnreadableAgentSkillSource( + sources: readonly SkillDiscoverySource[], + sourceKinds?: readonly SkillSourceKind[] +): boolean { + return sources.some( + (source) => + source.skippedReason === 'unavailable' && + (!sourceKinds || sourceKinds.includes(source.sourceKind)) + ) +} + +export type InstalledAgentSkillScan = { + enabled: boolean + installed: boolean + /** A scan answered for this target; a cached answer counts. */ + settled: boolean + /** The scan's own failure, before the advisory below is folded in. */ + error: string | null + sources: readonly SkillDiscoverySource[] + sourceKinds?: readonly SkillSourceKind[] +} + +export type InstalledAgentSkillVerdict = { + /** Nothing proves the skill absent, so no surface may render it as undone. */ + installedUnverifiable: boolean + /** The scan's own failure, else the advisory an unverifiable negative earns. */ + error: string | null +} + +/** + * Finding the skill is proof, so only a negative is ever doubted. Two shapes + * qualify: a scan that answered without reading a root this query cares about, + * and a scan that never answered at all — invisible to `sources`, which stay + * empty until a result lands. A failed refresh over an answer already held is + * neither: that answer still stands. + */ +export function getInstalledAgentSkillVerdict( + scan: InstalledAgentSkillScan +): InstalledAgentSkillVerdict { + const installedUnverifiable = + scan.enabled && + !scan.installed && + (hasUnreadableAgentSkillSource(scan.sources, scan.sourceKinds) || + (!scan.settled && scan.error !== null)) + return { + installedUnverifiable, + error: + scan.error ?? + (installedUnverifiable + ? translate( + 'auto.hooks.useInstalledAgentSkills.unreadableSkillSource', + 'A skill folder did not respond, so this status may be incomplete.' + ) + : null) + } +} diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx index f8fd67e6b43..6a682fb0f1e 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx +++ b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx @@ -735,6 +735,8 @@ describe('useInstalledAgentSkill', () => { // fresh discovery per store write for as long as the host stays unreachable. expect(discover).toHaveBeenCalledTimes(1) expect(latestState?.error).toBe('runtime host unreachable') + // No result ever landed, so "not installed" is a claim this scan cannot back. + expect(latestState?.installedUnverifiable).toBe(true) }) it('hydrates from the warm cache on its very first render pass', async () => { @@ -882,6 +884,34 @@ describe('useInstalledAgentSkill', () => { expect(latestState?.installed).toBe(false) }) + it('keeps a landed answer authoritative when a later refresh fails', async () => { + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockResolvedValueOnce(discoveryResult([])) + .mockRejectedValue(new Error('refresh failed')) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + await flushMicrotasks() + expect(discover).toHaveBeenCalledTimes(1) + expect(latestState?.settled).toBe(true) + expect(latestState?.installedUnverifiable).toBe(false) + + await act(async () => { + notifyInstalledAgentSkillsChanged() + }) + await flushMicrotasks() + + // The refresh failed, but the answer the scan already landed still stands. + expect(discover).toHaveBeenCalledTimes(2) + expect(latestState?.error).toBe('refresh failed') + expect(latestState?.settled).toBe(true) + expect(latestState?.installedUnverifiable).toBe(false) + }) + it('empties the discovery cache when an install notification fires', async () => { // Why: assert the cache directly — a mounted component forces a rescan and // would hide a missing invalidation. diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts index a3340a104e2..6e82faab9a0 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts @@ -1,16 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { - DiscoveredSkill, - SkillDiscoveryResult, - SkillDiscoverySource -} from '../../../shared/skills' +import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../shared/skills' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, _installedAgentSkillDiscoveryInternalsForTests, hasInstalledAgentSkill, hasInstalledAgentSkillNamed, - hasUnreadableAgentSkillSource, notifyInstalledAgentSkillsRefreshed } from './useInstalledAgentSkills' @@ -162,44 +157,6 @@ describe('hasInstalledAgentSkill', () => { }) }) -describe('hasUnreadableAgentSkillSource', () => { - function source(overrides: Partial): SkillDiscoverySource { - return { - id: 'home', - label: 'Agent skills home', - path: '/Users/test/.agents/skills', - sourceKind: 'home', - providers: ['agent-skills'], - owner: null, - // An unread root reports `exists`: the host could not prove otherwise. - exists: true, - ...overrides - } - } - - it('flags a root that did not answer even though it reports as present', () => { - expect(hasUnreadableAgentSkillSource([source({ skippedReason: 'unavailable' })])).toBe(true) - }) - - it('ignores roots that were scanned or are genuinely absent', () => { - expect( - hasUnreadableAgentSkillSource([ - source({}), - source({ id: 'gone', exists: false, skippedReason: 'missing' }) - ]) - ).toBe(false) - }) - - it('ignores an unread root outside the scopes the caller asked about', () => { - expect( - hasUnreadableAgentSkillSource( - [source({ id: 'repo', sourceKind: 'repo', skippedReason: 'unavailable' })], - GLOBAL_AGENT_SKILL_SOURCE_KINDS - ) - ).toBe(false) - }) -}) - describe('isOrchestrationSkillName', () => { it('matches only the orchestration skill name', () => { expect( diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index d7ac999a873..42115bf8206 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -8,7 +8,6 @@ import type { } from '../../../shared/skills' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state' -import { translate } from '@/i18n/i18n' import { discoverInstalledAgentSkills, getCachedSkillDiscovery, @@ -16,6 +15,10 @@ import { getSkillDiscoveryTargetKey, resetSkillDiscoveryCacheForTests } from './installed-agent-skill-discovery' +import { + getInstalledAgentSkillVerdict, + type InstalledAgentSkillScan +} from './installed-agent-skill-verdict' import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT, INSTALLED_AGENT_SKILLS_REFRESHED_EVENT @@ -48,6 +51,8 @@ export type InstalledAgentSkillState = { // Why: a forced rescan keeps the previous result, so only the first scan per // runtime-scoped target is genuinely unknown. settled: boolean + // A negative this scan cannot vouch for: render it as unknown, not as undone. + installedUnverifiable: boolean error: string | null skills: readonly DiscoveredSkill[] sources: readonly SkillDiscoverySource[] @@ -94,23 +99,6 @@ export function hasInstalledAgentSkillNamed( }) } -/** - * True when a root this query cares about did not answer, so its skills are - * unknown rather than absent. The host serves such a root's last answer, but a - * root that has never answered has none to serve, and a bare "Not installed" - * there offers Install for a skill that may already be present. - */ -export function hasUnreadableAgentSkillSource( - sources: readonly SkillDiscoverySource[], - sourceKinds?: readonly SkillSourceKind[] -): boolean { - return sources.some( - (source) => - source.skippedReason === 'unavailable' && - (!sourceKinds || sourceKinds.includes(source.sourceKind)) - ) -} - export function notifyInstalledAgentSkillsRefreshed(): void { if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_REFRESHED_EVENT)) @@ -323,10 +311,15 @@ export function useInstalledAgentSkillNames( [candidateSkillNames, enabled, skills, sourceKinds] ) - const incompleteScan = useMemo( - () => enabled && !installed && hasUnreadableAgentSkillSource(sources, sourceKinds), - [enabled, installed, sources, sourceKinds] - ) + const settled = enabled && resultForRender !== null + const scan: InstalledAgentSkillScan = { + enabled, + installed, + settled, + error: errorForRender, + sources, + sourceKinds + } useEffect(() => { if (installed && candidateSkillNames.some(isOrchestrationSkillName)) { @@ -341,15 +334,8 @@ export function useInstalledAgentSkillNames( return { installed, loading: loadingForRender, - settled: enabled && resultForRender !== null, - error: - errorForRender ?? - (incompleteScan - ? translate( - 'auto.hooks.useInstalledAgentSkills.unreadableSkillSource', - 'A skill folder did not respond, so this status may be incomplete.' - ) - : null), + settled, + ...getInstalledAgentSkillVerdict(scan), skills, sources, refresh: forceRefresh diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9be44123070..d1f40e5fe1d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11519,7 +11519,8 @@ "noteKeysBody": "API keys and workspaces are stored for the active runtime.", "noteVisibilityTitle": "Hiding ≠ disconnect", "noteVisibilityBody": "Hiding Linear in Task Sources only removes it from the picker. It does not remove your key or skill.", - "setupChecking": "Checking…" + "setupChecking": "Checking…", + "setupUnverified": "Cannot verify" }, "TaskSourceLinearSetup": { "connectTitle": "Connect Linear", @@ -11545,7 +11546,8 @@ "statusHidden": "Hidden from Tasks", "statusIncomplete": "Needs setup", "collapseSetup": "Collapse {{provider}} setup steps", - "expandSetup": "Show {{provider}} setup steps" + "expandSetup": "Show {{provider}} setup steps", + "statusUnverified": "Cannot verify" }, "TaskSourceShowInTasksStep": { "shown": "Shown", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 125876be1a0..eb8a63674e8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10269,7 +10269,8 @@ "noteKeysBody": "API 密钥和工作区存储在当前运行环境中。", "noteVisibilityTitle": "隐藏不等于断开连接", "noteVisibilityBody": "在任务来源中隐藏 Linear 只会将其从选择器中移除,不会删除密钥或技能。", - "setupChecking": "检查中…" + "setupChecking": "检查中…", + "setupUnverified": "无法验证" }, "TaskSourceLinearSetup": { "connectTitle": "连接 Linear", @@ -10291,6 +10292,7 @@ "statusReady": "已就绪", "statusConnectRequired": "需要连接", "statusSkillRequired": "需要技能", + "statusUnverified": "无法验证", "statusUnavailable": "状态不可用", "statusHidden": "已从任务中隐藏", "statusIncomplete": "需要设置", diff --git a/src/renderer/src/i18n/settings-status-label-localization.test.ts b/src/renderer/src/i18n/settings-status-label-localization.test.ts index bcbb0837090..1bb8fa312c0 100644 --- a/src/renderer/src/i18n/settings-status-label-localization.test.ts +++ b/src/renderer/src/i18n/settings-status-label-localization.test.ts @@ -52,6 +52,9 @@ const REQUIRED_KEYS: Record = { 'auto.components.settings.ComputerUsePane.statusGranted': 'Granted', 'auto.components.settings.ComputerUsePane.statusUnsupported': 'macOS only', 'auto.components.settings.ComputerUsePane.statusNotEnabled': 'Not enabled', + // Linear setup checklist — unverifiable skill scan (Settings pane + Task Sources card) + 'auto.components.settings.LinearAgentSkillGuide.setupUnverified': 'Cannot verify', + 'auto.components.settings.TaskSourceProviderCard.statusUnverified': 'Cannot verify', // Source-control CLI integration cards 'auto.components.settings.cli.source.control.integration.cards.statusConnected': 'Connected', 'auto.components.settings.cli.source.control.integration.cards.statusUnavailable': 'Unavailable',