mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16:02:38 +00:00
fix(linear): show 'Cannot verify' when skill scan is inconclusive
A scan that encounters an error before discovering skills, or hits an unreadable root, cannot vouch for "not installed". Previously these cases were conflated with proven absence, so the checklist would claim the skill step incomplete even when all three steps had been finished. Now the UI distinguishes between confirmed states and unknown ones, showing "Cannot verify" instead of listing the skill as an unfinished step.
This commit is contained in:
@@ -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<LinearSetupReadiness>): string {
|
||||
return renderToStaticMarkup(
|
||||
<LinearAgentSkillGuide
|
||||
readiness={{ ...baseReadiness, ...readiness }}
|
||||
onOpenTaskSources={vi.fn()}
|
||||
onManageLinearAccess={vi.fn()}
|
||||
skillPanel={<div data-testid="skill-panel">Skill install panel</div>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('LinearAgentSkillGuide', () => {
|
||||
it('renders the setup checklist with an inlined skill panel', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<LinearAgentSkillGuide
|
||||
status={baseStatus}
|
||||
onOpenTaskSources={vi.fn()}
|
||||
onManageLinearAccess={vi.fn()}
|
||||
skillPanel={<div data-testid="skill-panel">Skill install panel</div>}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<LinearAgentSkillGuide
|
||||
status={{
|
||||
...baseStatus,
|
||||
skillInstalled: true
|
||||
}}
|
||||
onOpenTaskSources={vi.fn()}
|
||||
onManageLinearAccess={vi.fn()}
|
||||
skillPanel={<div>Skill panel</div>}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<LinearAgentSkillGuide
|
||||
status={{
|
||||
...baseStatus,
|
||||
skillInstalled: true,
|
||||
skillChecking: true
|
||||
}}
|
||||
onOpenTaskSources={vi.fn()}
|
||||
onManageLinearAccess={vi.fn()}
|
||||
skillPanel={<div>Skill panel</div>}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<LinearAgentSkillGuide
|
||||
status={{
|
||||
...baseStatus,
|
||||
skillInstalled: true,
|
||||
connectionChecking: true
|
||||
}}
|
||||
onOpenTaskSources={vi.fn()}
|
||||
onManageLinearAccess={vi.fn()}
|
||||
skillPanel={<div>Skill panel</div>}
|
||||
/>
|
||||
)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// 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 (
|
||||
<span className="flex size-5 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
|
||||
<TriangleAlert className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (done) {
|
||||
return (
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
@@ -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 (
|
||||
<section className="space-y-3 rounded-xl border border-border/60 bg-card/30 p-4">
|
||||
@@ -83,23 +145,22 @@ export function LinearAgentSkillGuide({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<IntegrationStatusPill tone={checking ? 'neutral' : allReady ? 'connected' : 'attention'}>
|
||||
{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 }
|
||||
)}
|
||||
</IntegrationStatusPill>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<IntegrationStatusPill tone={pill.tone}>{pill.label}</IntegrationStatusPill>
|
||||
{pill.showCount ? (
|
||||
// Mirrors the Task Sources card so the confirmed count survives a label
|
||||
// that no longer carries it.
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{`${completed}/${total}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/50">
|
||||
<div className="flex flex-wrap items-start gap-3 py-3">
|
||||
<div className="mt-0.5">
|
||||
<SetupStatusIcon done={status.connected} checking={status.connectionChecking} />
|
||||
<SetupStatusIcon done={readiness.connected} checking={readiness.checking} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
@@ -118,11 +179,11 @@ export function LinearAgentSkillGuide({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status.connected ? 'outline' : 'default'}
|
||||
variant={readiness.connected ? 'outline' : 'default'}
|
||||
className="shrink-0"
|
||||
onClick={onManageLinearAccess}
|
||||
>
|
||||
{status.connected
|
||||
{readiness.connected
|
||||
? translate(
|
||||
'auto.components.settings.LinearAgentSkillGuide.manageKeys',
|
||||
'Manage keys'
|
||||
@@ -134,7 +195,11 @@ export function LinearAgentSkillGuide({
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<SetupStatusIcon done={status.skillInstalled} checking={status.skillChecking} />
|
||||
<SetupStatusIcon
|
||||
done={readiness.skillInstalled}
|
||||
checking={readiness.skillChecking}
|
||||
unverifiable={readiness.skillUnverifiable}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
@@ -156,7 +221,7 @@ export function LinearAgentSkillGuide({
|
||||
|
||||
<div className="flex flex-wrap items-start gap-3 py-3">
|
||||
<div className="mt-0.5">
|
||||
<SetupStatusIcon done={status.visibleInTasks} checking={false} />
|
||||
<SetupStatusIcon done={readiness.visible} checking={false} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
@@ -175,7 +240,7 @@ export function LinearAgentSkillGuide({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status.visibleInTasks ? 'outline' : 'default'}
|
||||
variant={readiness.visible ? 'outline' : 'default'}
|
||||
className="shrink-0"
|
||||
onClick={onOpenTaskSources}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
panelProps: [] as Record<string, unknown>[],
|
||||
runtime: 'native' as 'native' | 'wsl',
|
||||
skillInstalled: true,
|
||||
skillUnverifiable: false,
|
||||
updateSkillName: 'orca-linear',
|
||||
linearConnected: true,
|
||||
visibleTaskProviders: ['github', 'linear'] as string[],
|
||||
@@ -58,8 +59,11 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({
|
||||
useInstalledAgentSkillNames: () => ({
|
||||
installed: mocks.skillInstalled,
|
||||
loading: false,
|
||||
settled: true,
|
||||
installedUnverifiable: mocks.skillUnverifiable,
|
||||
error: null,
|
||||
skills: [],
|
||||
sources: [],
|
||||
refresh: vi.fn()
|
||||
})
|
||||
}))
|
||||
@@ -135,6 +139,7 @@ describe('LinearAgentSkillPane', () => {
|
||||
mocks.panelProps.length = 0
|
||||
mocks.runtime = 'native'
|
||||
mocks.skillInstalled = true
|
||||
mocks.skillUnverifiable = false
|
||||
mocks.updateSkillName = 'orca-linear'
|
||||
mocks.linearConnected = true
|
||||
mocks.visibleTaskProviders = ['github', 'linear']
|
||||
@@ -210,6 +215,15 @@ describe('LinearAgentSkillPane', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reports an unverifiable skill scan as unknown instead of an unfinished step', () => {
|
||||
mocks.skillInstalled = false
|
||||
mocks.skillUnverifiable = true
|
||||
const markup = renderToStaticMarkup(<LinearAgentSkillPane />)
|
||||
|
||||
expect(markup).toContain('Cannot verify')
|
||||
expect(markup).not.toContain('2 of 3 ready')
|
||||
})
|
||||
|
||||
it('shows incomplete checklist when the skill is missing', () => {
|
||||
mocks.skillInstalled = false
|
||||
const markup = renderToStaticMarkup(<LinearAgentSkillPane />)
|
||||
|
||||
@@ -107,12 +107,13 @@ export function LinearAgentSkillPane(): React.JSX.Element {
|
||||
className="space-y-6 py-2"
|
||||
>
|
||||
<LinearAgentSkillGuide
|
||||
status={{
|
||||
readiness={{
|
||||
connected: linearConnected,
|
||||
connectionChecking,
|
||||
checking: connectionChecking,
|
||||
skillInstalled: skillSetup.skillInstalled,
|
||||
skillChecking: skillSetup.skillChecking,
|
||||
visibleInTasks
|
||||
skillUnverifiable: skillSetup.skillUnverifiable,
|
||||
visible: visibleInTasks
|
||||
}}
|
||||
onOpenTaskSources={openTaskSources}
|
||||
onManageLinearAccess={
|
||||
|
||||
@@ -129,4 +129,23 @@ describe('TaskSourceProviderCard', () => {
|
||||
expect(markup).toContain('>Shown</button>')
|
||||
expect(markup).not.toContain('>Hide</button>')
|
||||
})
|
||||
|
||||
it('labels an unverifiable skill scan as unknown while keeping the confirmed count', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TaskSourceProviderCard
|
||||
icon={<span />}
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {}): InstalledAgentSkillScan {
|
||||
return {
|
||||
enabled: true,
|
||||
installed: false,
|
||||
settled: true,
|
||||
error: null,
|
||||
sources: [],
|
||||
sourceKinds: ['home'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const unverifiable = (overrides: Partial<InstalledAgentSkillScan>): boolean =>
|
||||
getInstalledAgentSkillVerdict(scan(overrides)).installedUnverifiable
|
||||
const error = (overrides: Partial<InstalledAgentSkillScan>): 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<SkillDiscoveryResult>>()
|
||||
.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.
|
||||
|
||||
@@ -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>): 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "需要设置",
|
||||
|
||||
@@ -52,6 +52,9 @@ const REQUIRED_KEYS: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
Reference in New Issue
Block a user