diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e36e6ab8943..b65276196b5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2825,12 +2825,20 @@ function App(): React.JSX.Element { > + {/* Why: the dialog hosts a live terminal pane, which requires the + link-routing preference context; mounting outside crashes it. */} + + + - {/* Why: rendered last so it sits after all -webkit-app-region:drag elements in DOM order. Electron's hit-test for drag regions is DOM-order-based and diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index d7f4da5b619..4e986a2686f 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react' import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react' import { toast } from 'sonner' import { IntegrationStatusPill } from '../integration-status-pill' +import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' @@ -47,6 +48,10 @@ type AgentSkillSetupPanelProps = { openingHint?: ReactNode footer?: ReactNode onRecheck: () => void | Promise + // Why: when set, the installed pill reflects skill freshness and Re-check also + // refreshes the freshness inventory. Callers omit it for non-local runtimes, + // which the local-host-only freshness scan cannot vouch for. + freshnessSkillName?: string } export function AgentSkillSetupPanel({ @@ -79,7 +84,8 @@ export function AgentSkillSetupPanel({ actionHint, openingHint, footer, - onRecheck + onRecheck, + freshnessSkillName }: AgentSkillSetupPanelProps): React.JSX.Element { const [terminalOpen, setTerminalOpen] = useState(false) const [terminalCommand, setTerminalCommand] = useState(null) @@ -211,7 +217,12 @@ export function AgentSkillSetupPanel({ variant="ghost" size="sm" className="gap-1.5" - onClick={() => void onRecheck()} + onClick={() => { + void onRecheck() + if (freshnessSkillName) { + notifyInstalledAgentSkillsChanged() + } + }} disabled={loading} > @@ -264,12 +275,16 @@ export function AgentSkillSetupPanel({ )} ) : installed ? ( - - {translate( - 'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', - 'Installed' - )} - + freshnessSkillName ? ( + + ) : ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', + 'Installed' + )} + + ) ) : ( {translate( diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index bb830eb63fa..eae2ce2a76b 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -22,7 +22,6 @@ import { Button } from '../ui/button' import { Label } from '../ui/label' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' -import { SkillFreshnessCheckButton } from '../skills/SkillFreshnessCheckButton' import { CliRegistrationDialog } from './CliRegistrationDialog' import { buildSkillCommandForRuntime, @@ -391,9 +390,8 @@ export function CliSection({ })) }} onRecheck={refreshCliSkill} + freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined} /> - - ) : null} diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 270e1785aef..0ffc0a2b526 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshComputerUseSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME + } /> ) } diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 85b27f12ed7..f1a34a0396f 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -163,6 +163,9 @@ export function EphemeralVmsPane(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME + } />
diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index b757261bba6..d597895d580 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -7,6 +7,7 @@ import { ORCHESTRATION_SKILL_INSTALL_COMMAND, ORCHESTRATION_SKILL_UPDATE_COMMAND } from '@/lib/orchestration-install-command' +import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' @@ -75,6 +76,9 @@ export function OrchestrationSetupCard(props: { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={skill.refresh} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME + } /> ) diff --git a/src/renderer/src/components/skills/SkillFreshnessCheckButton.test.tsx b/src/renderer/src/components/skills/SkillFreshnessCheckButton.test.tsx deleted file mode 100644 index 5d93e600d2a..00000000000 --- a/src/renderer/src/components/skills/SkillFreshnessCheckButton.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SkillFreshnessCheckButton } from './SkillFreshnessCheckButton' -import { - consumeSkillFreshnessUpdateDialogRequest, - subscribeSkillFreshnessUpdateDialog -} from './skill-freshness-update-dialog' - -let root: Root | null = null -let container: HTMLDivElement | null = null - -async function renderButton(): Promise { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - await act(async () => { - root?.render() - }) -} - -describe('SkillFreshnessCheckButton', () => { - beforeEach(() => { - consumeSkillFreshnessUpdateDialogRequest() - }) - - afterEach(async () => { - if (root) { - await act(async () => root?.unmount()) - } - root = null - container?.remove() - container = null - }) - - it('requests the update dialog when clicked', async () => { - const listener = vi.fn() - const unsubscribe = subscribeSkillFreshnessUpdateDialog(listener) - await renderButton() - - const button = container?.querySelector('button') - expect(button?.textContent).toContain('Check for skill updates') - await act(async () => { - button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(listener).toHaveBeenCalledTimes(1) - expect(consumeSkillFreshnessUpdateDialogRequest()).toBe(true) - unsubscribe() - }) -}) diff --git a/src/renderer/src/components/skills/SkillFreshnessCheckButton.tsx b/src/renderer/src/components/skills/SkillFreshnessCheckButton.tsx deleted file mode 100644 index e0f53a10e96..00000000000 --- a/src/renderer/src/components/skills/SkillFreshnessCheckButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { RefreshCw } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { translate } from '@/i18n/i18n' -import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' - -// Compact re-entry point for the freshness update dialog, mounted at App root. -// Works even when nothing is outdated: the dialog then shows the up-to-date or -// diagnostic state. -export function SkillFreshnessCheckButton({ - className -}: { - className?: string -}): React.JSX.Element { - return ( - - ) -} diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx new file mode 100644 index 00000000000..3a26115e00f --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: null, + refresh: vi.fn() + }) +})) + +function inventory( + entries: { name: string; status: 'current' | 'outdated' | 'unrecognized' }[], + eligibleUpdateNames: string[] +): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: entries.map((entry, index) => ({ + id: `${entry.name}-${index}`, + name: entry.name, + description: null, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${entry.name}`, + resolvedPath: `/home/.agents/skills/${entry.name}`, + physicalIdentity: `physical-${entry.name}-${index}`, + topology: 'canonical-copy', + status: entry.status, + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null + })), + eligibleUpdateNames, + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderPill(skillName: string): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + +describe('SkillFreshnessStatusPill', () => { + beforeEach(() => { + mocks.inventory = null + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('shows Update available for an eligible outdated skill', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli']) + + expect((await renderPill('orca-cli')).textContent).toBe('Update available') + }) + + it('shows Up to date when every placement is current', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'current' }], []) + + expect((await renderPill('orca-cli')).textContent).toBe('Up to date') + }) + + it('falls back to Installed for a blocked outdated placement', async () => { + mocks.inventory = inventory( + [ + { name: 'orca-cli', status: 'outdated' }, + { name: 'orca-cli', status: 'unrecognized' } + ], + [] + ) + + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) + + it('falls back to Installed before the inventory loads', async () => { + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx new file mode 100644 index 00000000000..3c858686591 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -0,0 +1,40 @@ +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { IntegrationStatusPill } from '@/components/integration-status-pill' + +// Why: the setup rails' Installed pill is presence-only; when freshness knows a +// safe update exists (or that every copy is current) the pill should say so. +// Falls back to plain Installed for blocked/unrecognized copies so an unsafe +// placement is never advertised as updatable here. +export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { + const { inventory } = useSkillFreshness() + if (inventory?.eligibleUpdateNames.includes(skillName)) { + return ( + + {translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + )} + + ) + } + const placements = inventory?.installations.filter( + (installation) => installation.name === skillName + ) + if ( + placements && + placements.length > 0 && + placements.every((installation) => installation.status === 'current') + ) { + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')} + + ) + } + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.installed', 'Installed')} + + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a6b5cf9a99a..43c17063511 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3641,8 +3641,10 @@ "checkNow": "Check now", "close": "Close" }, - "SkillFreshnessCheckButton": { - "label": "Check for skill updates" + "SkillFreshnessStatusPill": { + "updateAvailable": "Update available", + "upToDate": "Up to date", + "installed": "Installed" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 208c9e8733a..292dcb560e3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3641,8 +3641,10 @@ "checkNow": "Comprobar ahora", "close": "Cerrar" }, - "SkillFreshnessCheckButton": { - "label": "Buscar actualizaciones de skills" + "SkillFreshnessStatusPill": { + "updateAvailable": "Actualización disponible", + "upToDate": "Actualizado", + "installed": "Instalado" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 21bc3ca2318..029fc3f934a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3641,8 +3641,10 @@ "checkNow": "今すぐ確認", "close": "閉じる" }, - "SkillFreshnessCheckButton": { - "label": "スキルの更新を確認" + "SkillFreshnessStatusPill": { + "updateAvailable": "更新があります", + "upToDate": "最新です", + "installed": "インストール済み" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6a9060b8254..973db5dbbce 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3641,8 +3641,10 @@ "checkNow": "지금 확인", "close": "닫기" }, - "SkillFreshnessCheckButton": { - "label": "스킬 업데이트 확인" + "SkillFreshnessStatusPill": { + "updateAvailable": "업데이트 가능", + "upToDate": "최신 상태", + "installed": "설치됨" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 60133b0b16e..fd986fb5efc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3641,8 +3641,10 @@ "checkNow": "立即检查", "close": "关闭" }, - "SkillFreshnessCheckButton": { - "label": "检查技能更新" + "SkillFreshnessStatusPill": { + "updateAvailable": "有可用更新", + "upToDate": "已是最新", + "installed": "已安装" } }, "sidebar": {