Restore Linear agent-skill setup prompt and add provider-card install CTA

PR #5071 (36277801e) accidentally dropped the LinearAgentSkillSetupPrompt
modal from WorktreeCard, orphaning the component. Restore the exact
wiring: render on the active worktree when it has a linked Linear issue.

Also surface the decoupled orca-linear agent skill on the Linear task
provider settings card: install state via useInstalledAgentSkillNames,
copyable install/update command resolved for the agent runtime, and a
remote-setup note when a runtime environment is active. The legacy-aware
update-command selection moves into a shared lib module so the sidebar
prompt and the new CTA stay in sync.
This commit is contained in:
Brennan Benson
2026-07-09 17:36:39 -07:00
parent 4ead072a16
commit 47629aaa2b
13 changed files with 541 additions and 23 deletions
@@ -44,8 +44,10 @@ const updateCapableCallers = new Map<string, readonly string[]>([
['ORCA_CLI_SKILL_UPDATE_COMMAND', 'installedCommand={updateCommand}']
],
[
// Why: the single-skill update command selection moved into
// getLinearAgentSkillUpdateCommand so the settings install CTA shares it.
'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx',
['ORCA_LINEAR_SKILL_UPDATE_COMMAND', 'installedCommand={installedCommand}']
['getLinearAgentSkillUpdateCommand', 'installedCommand={installedCommand}']
],
[
'src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx',
@@ -0,0 +1,185 @@
// @vitest-environment happy-dom
import { act, type ComponentProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { DiscoveredSkill } from '../../../../shared/skills'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '../ui/tooltip'
import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta'
const mocks = vi.hoisted(() => ({
skillState: {
installed: false,
loading: false,
error: null as string | null,
skills: [] as DiscoveredSkill[],
refresh: vi.fn(async () => true)
},
useInstalledAgentSkillNames: vi.fn(),
clipboardWrite: vi.fn(async () => {}),
toastSuccess: vi.fn(),
toastError: vi.fn()
}))
vi.mock('@/hooks/useInstalledAgentSkills', async (importOriginal) => ({
...(await importOriginal()),
useInstalledAgentSkillNames: mocks.useInstalledAgentSkillNames
}))
vi.mock('./CliSkillRuntimeSetup', () => ({
buildSkillCommandForRuntime: (command: string) => command
}))
vi.mock('sonner', () => ({
toast: { success: mocks.toastSuccess, error: mocks.toastError }
}))
let root: Root | null = null
let container: HTMLDivElement | null = null
function discoveredSkill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
return {
id: 'skill-1',
name: 'orca-linear',
description: null,
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
rootPath: '/Users/test/.agents/skills',
directoryPath: '/Users/test/.agents/skills/orca-linear',
skillFilePath: '/Users/test/.agents/skills/orca-linear/SKILL.md',
installed: true,
fileCount: 1,
updatedAt: null,
...overrides
}
}
async function renderCta(
props: Partial<ComponentProps<typeof LinearAgentSkillInstallCta>> = {}
): Promise<HTMLDivElement> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(
<TooltipProvider>
<LinearAgentSkillInstallCta
settings={{ activeRuntimeEnvironmentId: null, terminalWindowsShell: '' }}
{...props}
/>
</TooltipProvider>
)
})
if (!container) {
throw new Error('Container was not created')
}
return container
}
describe('LinearAgentSkillInstallCta', () => {
beforeEach(() => {
mocks.skillState.installed = false
mocks.skillState.loading = false
mocks.skillState.error = null
mocks.skillState.skills = []
mocks.skillState.refresh.mockClear()
mocks.useInstalledAgentSkillNames.mockReset()
mocks.useInstalledAgentSkillNames.mockReturnValue(mocks.skillState)
mocks.clipboardWrite.mockClear()
mocks.toastSuccess.mockClear()
mocks.toastError.mockClear()
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { writeClipboardText: mocks.clipboardWrite } }
})
})
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
root = null
container?.remove()
container = null
Reflect.deleteProperty(window, 'api')
})
it('shows the install command and explanation when the skill is missing', async () => {
const rendered = await renderCta()
expect(rendered.textContent).toContain('Agent skill:')
expect(rendered.textContent).toContain('orca-linear')
expect(rendered.textContent).toContain('Not installed')
expect(rendered.textContent).toContain('Let your agents read and edit Linear tasks.')
expect(rendered.textContent).toContain(
'npx skills add https://github.com/stablyai/orca --skill orca-linear --global'
)
})
it('copies the install command to the clipboard', async () => {
const rendered = await renderCta()
await act(async () => {
rendered
.querySelector<HTMLButtonElement>('button[aria-label="Copy command"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.clipboardWrite).toHaveBeenCalledWith(
'npx skills add https://github.com/stablyai/orca --skill orca-linear --global'
)
expect(mocks.toastSuccess).toHaveBeenCalled()
})
it('shows a subtle confirmation and the update command when installed', async () => {
mocks.skillState.installed = true
mocks.skillState.skills = [discoveredSkill({ name: 'orca-linear' })]
const rendered = await renderCta()
expect(rendered.textContent).toContain('Installed')
expect(rendered.textContent).toContain('Agent skill installed. To update it, run:')
expect(rendered.textContent).toContain('npx skills update orca-linear --global')
expect(rendered.textContent).not.toContain('Not installed')
})
it('updates through the legacy skill name when only linear-tickets is installed', async () => {
mocks.skillState.installed = true
mocks.skillState.skills = [
discoveredSkill({
name: 'linear-tickets',
directoryPath: '/Users/test/.agents/skills/linear-tickets',
skillFilePath: '/Users/test/.agents/skills/linear-tickets/SKILL.md'
})
]
const rendered = await renderCta()
expect(rendered.textContent).toContain('npx skills update linear-tickets --global')
})
it('notes that remote agent environments need their own setup', async () => {
const rendered = await renderCta({
settings: { activeRuntimeEnvironmentId: 'runtime-1', terminalWindowsShell: '' }
})
expect(rendered.textContent).toContain(
'This installs host setup; remote agent environments may need separate setup.'
)
})
it('re-checks the skill scan on demand', async () => {
const rendered = await renderCta()
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Re-check'))
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.skillState.refresh).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,186 @@
import { useMemo } from 'react'
import { Copy, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { IntegrationStatusPill } from '@/components/integration-status-pill'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkillNames
} from '@/hooks/useInstalledAgentSkills'
import {
LINEAR_AGENT_SKILL_NAMES,
ORCA_LINEAR_SKILL_INSTALL_COMMAND,
ORCA_LINEAR_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import { getLinearAgentSkillUpdateCommand } from '@/lib/linear-agent-skill-update-command'
import { cn } from '@/lib/utils'
import { getLinearAgentSkillSetupInlineRuntimeCopy } from '../sidebar/linear-agent-skill-setup-copy'
import {
getCurrentPlatform,
getLinearPromptAgentRuntime,
getLinearPromptSkillDiscoveryTarget,
type LinearAgentSkillPromptSettings
} from '../sidebar/linear-agent-skill-runtime'
import { buildSkillCommandForRuntime } from './CliSkillRuntimeSetup'
import {
useIntegrationCommandRowClass,
useIntegrationSubordinateRowClass
} from './integration-card-presentation'
import { translate } from '@/i18n/i18n'
type LinearAgentSkillInstallCtaProps = {
settings: LinearAgentSkillPromptSettings | null | undefined
}
// Why: the Linear task provider and the orca-linear agent skill are decoupled
// setups; this section bridges them so connecting the provider also surfaces
// the skill agents need to read and edit Linear tasks.
export function LinearAgentSkillInstallCta({
settings
}: LinearAgentSkillInstallCtaProps): React.JSX.Element {
// Why: mirror the sidebar setup prompt's runtime resolution so both surfaces
// agree on which machine (host vs. WSL distro) is scanned and installed to.
const remote = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const agentRuntime = useMemo(
() => getLinearPromptAgentRuntime(settings, getCurrentPlatform(), remote),
[remote, settings]
)
const skillDiscoveryTarget = useMemo(
() => getLinearPromptSkillDiscoveryTarget(agentRuntime),
[agentRuntime]
)
const skill = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, {
discoveryTarget: skillDiscoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const command = useMemo(
() =>
buildSkillCommandForRuntime(
skill.installed
? getLinearAgentSkillUpdateCommand(skill.skills, skill.installed)
: ORCA_LINEAR_SKILL_INSTALL_COMMAND,
agentRuntime
),
[agentRuntime, skill.installed, skill.skills]
)
const subordinateRowClass = useIntegrationSubordinateRowClass('space-y-1.5')
const commandRowClass = useIntegrationCommandRowClass()
const copyCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(command)
toast.success(
translate(
'auto.components.settings.linear.agent.skill.install.cta.copiedCommand',
'Copied command.'
)
)
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.linear.agent.skill.install.cta.copyFailed',
'Failed to copy command.'
)
)
}
}
return (
<div className={subordinateRowClass}>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-medium text-foreground">
{translate(
'auto.components.settings.linear.agent.skill.install.cta.skillLabel',
'Agent skill:'
)}{' '}
<span className="font-mono text-[11px]">{ORCA_LINEAR_SKILL_NAME}</span>
</p>
{skill.loading ? (
<IntegrationStatusPill tone="neutral">
{translate(
'auto.components.settings.linear.agent.skill.install.cta.checking',
'Checking...'
)}
</IntegrationStatusPill>
) : skill.installed ? (
<IntegrationStatusPill tone="connected">
{translate(
'auto.components.settings.linear.agent.skill.install.cta.installed',
'Installed'
)}
</IntegrationStatusPill>
) : (
<IntegrationStatusPill tone="attention">
{translate(
'auto.components.settings.linear.agent.skill.install.cta.notInstalled',
'Not installed'
)}
</IntegrationStatusPill>
)}
<Button
type="button"
variant="ghost"
size="xs"
className="ml-auto gap-1.5"
onClick={() => void skill.refresh()}
disabled={skill.loading}
>
<RefreshCw className={cn('size-3', skill.loading && 'animate-spin')} />
{translate('auto.components.settings.linear.agent.skill.install.cta.recheck', 'Re-check')}
</Button>
</div>
{skill.error ? <p className="text-xs text-destructive">{skill.error}</p> : null}
{!skill.loading && (
<>
<p className="text-xs text-muted-foreground">
{skill.installed
? translate(
'auto.components.settings.linear.agent.skill.install.cta.installedDescription',
'Agent skill installed. To update it, run:'
)
: translate(
'auto.components.settings.linear.agent.skill.install.cta.description',
'Let your agents read and edit Linear tasks.'
)}
</p>
<div className={commandRowClass}>
<code className="scrollbar-sleek min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{command}
</code>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label={translate(
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
'Copy command'
)}
onClick={() => void copyCommand()}
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
'Copy command'
)}
</TooltipContent>
</Tooltip>
</div>
{remote || agentRuntime.runtime === 'wsl' ? (
<p className="text-[11px] text-muted-foreground/70">
{getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)}
</p>
) : null}
</>
)}
</div>
)
}
@@ -2,9 +2,10 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { TooltipProvider } from '../ui/tooltip'
import { LinearIntegrationCard } from './task-tracker-integration-cards'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
@@ -86,12 +87,27 @@ async function renderCard(): Promise<HTMLDivElement> {
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<LinearIntegrationCard />)
root?.render(
<TooltipProvider>
<LinearIntegrationCard />
</TooltipProvider>
)
})
return container
}
describe('LinearIntegrationCard account scope', () => {
beforeEach(() => {
// Why: the embedded agent-skill CTA scans installed skills through
// window.api; without a stub it renders a scan error instead of a state.
Object.defineProperty(window, 'api', {
configurable: true,
value: {
skills: { discover: async () => ({ skills: [], sources: [], scannedAt: 0 }) }
}
})
})
afterEach(async () => {
if (root) {
await act(async () => {
@@ -102,6 +118,7 @@ describe('LinearIntegrationCard account scope', () => {
container?.remove()
container = null
mocks.store.current = null
Reflect.deleteProperty(window, 'api')
})
it('shows local-client account ownership when Linear is disconnected', async () => {
@@ -8,6 +8,7 @@ import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { useAppStore } from '@/store'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { useIntegrationSubordinateRowClass } from './integration-card-presentation'
import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta'
import { getProviderAccountScope } from './provider-account-scope'
import { ProviderHostScopeControl } from './ProviderHostScopeControl'
import { translate } from '@/i18n/i18n'
@@ -200,6 +201,7 @@ export function LinearIntegrationCard(): React.JSX.Element {
</Button>
</>
) : null}
<LinearAgentSkillInstallCta settings={settings} />
</IntegrationCardDetails>
<LinearApiKeyDialog
@@ -5,17 +5,13 @@ import type { ProjectExecutionRuntimeResolution } from '../../../../shared/proje
import { Button } from '@/components/ui/button'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
hasInstalledAgentSkill,
useInstalledAgentSkillNames
} from '@/hooks/useInstalledAgentSkills'
import {
LINEAR_AGENT_SKILL_NAMES,
LINEAR_TICKETS_SKILL_NAME,
LINEAR_TICKETS_SKILL_UPDATE_COMMAND,
ORCA_LINEAR_SKILL_NAME,
ORCA_LINEAR_SKILL_INSTALL_COMMAND,
ORCA_LINEAR_SKILL_UPDATE_COMMAND
ORCA_LINEAR_SKILL_INSTALL_COMMAND
} from '@/lib/agent-feature-install-commands'
import { getLinearAgentSkillUpdateCommand } from '@/lib/linear-agent-skill-update-command'
import {
ensureOrcaCliAvailableForAgentSkillTerminal,
isOrcaCliAvailableOnPath
@@ -118,21 +114,13 @@ export function LinearAgentSkillSetupPrompt({
() => buildSkillCommandForRuntime(ORCA_LINEAR_SKILL_INSTALL_COMMAND, agentRuntime),
[agentRuntime]
)
const canonicalSkillInstalled = hasInstalledAgentSkill(skill.skills, ORCA_LINEAR_SKILL_NAME, {
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const legacySkillInstalled = hasInstalledAgentSkill(skill.skills, LINEAR_TICKETS_SKILL_NAME, {
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
// Why: legacy-only installs must update the installed legacy skill, while
// fresh/canonical/both-name states should move through the canonical name.
const updateCommand =
!skill.installed || canonicalSkillInstalled || !legacySkillInstalled
? ORCA_LINEAR_SKILL_UPDATE_COMMAND
: LINEAR_TICKETS_SKILL_UPDATE_COMMAND
const installedCommand = useMemo(
() => buildSkillCommandForRuntime(updateCommand, agentRuntime),
[agentRuntime, updateCommand]
() =>
buildSkillCommandForRuntime(
getLinearAgentSkillUpdateCommand(skill.skills, skill.installed),
agentRuntime
),
[agentRuntime, skill.installed, skill.skills]
)
const terminalShellOverride = getLinearPromptTerminalShellOverride(
currentPlatform,
@@ -23,6 +23,7 @@ import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer'
import WorktreeContextMenu from './WorktreeContextMenu'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt'
import WorktreeCardAgents from './WorktreeCardAgents'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
@@ -1727,6 +1728,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
)}
{isActive && worktree.linkedLinearIssue ? (
<LinearAgentSkillSetupPrompt
linked
remote={Boolean(repo?.connectionId || settings?.activeRuntimeEnvironmentId?.trim())}
surface="modal"
settings={settings}
/>
) : null}
{/* Why: inline agent list. Gated on the 'inline-agents' card
property so users can hide it. Layout coupling: this block
grows the card height dynamically — WorktreeList uses
+20
View File
@@ -8896,6 +8896,26 @@
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
},
"linear": {
"agent": {
"skill": {
"install": {
"cta": {
"copiedCommand": "Copied command.",
"copyFailed": "Failed to copy command.",
"skillLabel": "Agent skill:",
"checking": "Checking...",
"installed": "Installed",
"notInstalled": "Not installed",
"recheck": "Re-check",
"installedDescription": "Agent skill installed. To update it, run:",
"description": "Let your agents read and edit Linear tasks.",
"copyCommand": "Copy command"
}
}
}
}
}
},
"right": {
+20
View File
@@ -8896,6 +8896,26 @@
"reviewFirst": "Revisar primero",
"singular": "Guardar a nivel global no cambiará 1 repositorio con su propia receta.",
"tooltipTitle": "Anulaciones de repositorio"
},
"linear": {
"agent": {
"skill": {
"install": {
"cta": {
"copiedCommand": "Copied command.",
"copyFailed": "Failed to copy command.",
"skillLabel": "Agent skill:",
"checking": "Checking...",
"installed": "Installed",
"notInstalled": "Not installed",
"recheck": "Re-check",
"installedDescription": "Agent skill installed. To update it, run:",
"description": "Let your agents read and edit Linear tasks.",
"copyCommand": "Copy command"
}
}
}
}
}
},
"right": {
+20
View File
@@ -8896,6 +8896,26 @@
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
},
"linear": {
"agent": {
"skill": {
"install": {
"cta": {
"copiedCommand": "Copied command.",
"copyFailed": "Failed to copy command.",
"skillLabel": "Agent skill:",
"checking": "Checking...",
"installed": "Installed",
"notInstalled": "Not installed",
"recheck": "Re-check",
"installedDescription": "Agent skill installed. To update it, run:",
"description": "Let your agents read and edit Linear tasks.",
"copyCommand": "Copy command"
}
}
}
}
}
},
"right": {
+20
View File
@@ -8896,6 +8896,26 @@
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
},
"linear": {
"agent": {
"skill": {
"install": {
"cta": {
"copiedCommand": "Copied command.",
"copyFailed": "Failed to copy command.",
"skillLabel": "Agent skill:",
"checking": "Checking...",
"installed": "Installed",
"notInstalled": "Not installed",
"recheck": "Re-check",
"installedDescription": "Agent skill installed. To update it, run:",
"description": "Let your agents read and edit Linear tasks.",
"copyCommand": "Copy command"
}
}
}
}
}
},
"right": {
+20
View File
@@ -8896,6 +8896,26 @@
"reviewFirst": "Review first",
"singular": "Global saves won't change 1 repository with its own recipe.",
"tooltipTitle": "Repository overrides"
},
"linear": {
"agent": {
"skill": {
"install": {
"cta": {
"copiedCommand": "Copied command.",
"copyFailed": "Failed to copy command.",
"skillLabel": "Agent skill:",
"checking": "Checking...",
"installed": "Installed",
"notInstalled": "Not installed",
"recheck": "Re-check",
"installedDescription": "Agent skill installed. To update it, run:",
"description": "Let your agents read and edit Linear tasks.",
"copyCommand": "Copy command"
}
}
}
}
}
},
"right": {
@@ -0,0 +1,28 @@
import type { DiscoveredSkill } from '../../../shared/skills'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
hasInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import {
LINEAR_TICKETS_SKILL_NAME,
LINEAR_TICKETS_SKILL_UPDATE_COMMAND,
ORCA_LINEAR_SKILL_NAME,
ORCA_LINEAR_SKILL_UPDATE_COMMAND
} from '@/lib/agent-feature-install-commands'
// Why: legacy-only installs must update the installed legacy skill, while
// fresh/canonical/both-name states should move through the canonical name.
export function getLinearAgentSkillUpdateCommand(
skills: readonly DiscoveredSkill[],
installed: boolean
): string {
const canonicalSkillInstalled = hasInstalledAgentSkill(skills, ORCA_LINEAR_SKILL_NAME, {
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const legacySkillInstalled = hasInstalledAgentSkill(skills, LINEAR_TICKETS_SKILL_NAME, {
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
return !installed || canonicalSkillInstalled || !legacySkillInstalled
? ORCA_LINEAR_SKILL_UPDATE_COMMAND
: LINEAR_TICKETS_SKILL_UPDATE_COMMAND
}