diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts index 5b7b8e020cd..8992e73499a 100644 --- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts +++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts @@ -44,8 +44,10 @@ const updateCapableCallers = new Map([ ['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', diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx new file mode 100644 index 00000000000..a3a4a1db3dd --- /dev/null +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.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 { + 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> = {} +): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + + + + ) + }) + 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('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) + }) +}) diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx new file mode 100644 index 00000000000..43e755dcbe2 --- /dev/null +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx @@ -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 => { + 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 ( +
+
+

+ {translate( + 'auto.components.settings.linear.agent.skill.install.cta.skillLabel', + 'Agent skill:' + )}{' '} + {ORCA_LINEAR_SKILL_NAME} +

+ {skill.loading ? ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.checking', + 'Checking...' + )} + + ) : skill.installed ? ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.installed', + 'Installed' + )} + + ) : ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.notInstalled', + 'Not installed' + )} + + )} + +
+ {skill.error ?

{skill.error}

: null} + {!skill.loading && ( + <> +

+ {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.' + )} +

+
+ + {command} + + + + + + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.copyCommand', + 'Copy command' + )} + + +
+ {remote || agentRuntime.runtime === 'wsl' ? ( +

+ {getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)} +

+ ) : null} + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 0dd26191914..9fce3fc84fc 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -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 { document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render() + root?.render( + + + + ) }) 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 () => { diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx index aca7be03f84..6c2cc1b9575 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -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 { ) : null} + 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, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index e0692a7f3bc..20cf2861051 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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({ )} + {isActive && worktree.linkedLinearIssue ? ( + + ) : 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 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 177a31b2850..ec41cbe9bf4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e4c1f062caf..efd4fc74779 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 01e1c9d6973..8673d258de8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 203fa2c5789..891fef2b8ea 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cda02d6db46..e24a6f8621f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/linear-agent-skill-update-command.ts b/src/renderer/src/lib/linear-agent-skill-update-command.ts new file mode 100644 index 00000000000..69dbcce84d7 --- /dev/null +++ b/src/renderer/src/lib/linear-agent-skill-update-command.ts @@ -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 +}