fix(skills): mount update dialog inside the link-routing provider and fold freshness into the setup rails

The dialog hosts a live terminal pane that requires the link-routing
preference context; mounted outside the provider it crashed the renderer
the moment an eligible update existed (caught by live QA — unit tests
mock the terminal). It now mounts inside the provider behind its own
recoverable boundary.

The separate 'Check for skill updates' settings button is gone: the
setup rails' own pill now carries freshness (Update available / Up to
date, falling back to Installed for blocked or unrecognized copies and
for non-local runtimes the local-only scan cannot vouch for), and
Re-check refreshes both installation detection and the freshness
inventory. Wired for the CLI, Orchestration, Computer Use, and
Per-Workspace Environments rails.
This commit is contained in:
Brennan Benson
2026-07-14 15:18:17 -07:00
parent dc91b70ef4
commit f098a496da
15 changed files with 211 additions and 104 deletions
+9 -1
View File
@@ -2825,12 +2825,20 @@ function App(): React.JSX.Element {
>
<RecentTabSwitcher />
</RecoverableRenderErrorBoundary>
{/* Why: the dialog hosts a live terminal pane, which requires the
link-routing preference context; mounting outside crashes it. */}
<RecoverableRenderErrorBoundary
boundaryId="overlay.skill-freshness-update-dialog"
surface="overlay"
compact
>
<SkillFreshnessUpdateDialog />
</RecoverableRenderErrorBoundary>
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<SkillFreshnessNudge />
<SkillFreshnessUpdateDialog />
<PinnedTabCloseDialog />
{/* 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
@@ -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<unknown>
// 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<string | null>(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}
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
@@ -264,12 +275,16 @@ export function AgentSkillSetupPanel({
)}
</IntegrationStatusPill>
) : installed ? (
<IntegrationStatusPill tone="connected">
{translate(
'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a',
'Installed'
)}
</IntegrationStatusPill>
freshnessSkillName ? (
<SkillFreshnessStatusPill skillName={freshnessSkillName} />
) : (
<IntegrationStatusPill tone="connected">
{translate(
'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a',
'Installed'
)}
</IntegrationStatusPill>
)
) : (
<IntegrationStatusPill tone="attention">
{translate(
@@ -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}
/>
<SkillFreshnessCheckButton className="mt-3" />
</div>
) : null}
</div>
@@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
: ensureOrcaCliAvailableForAgentSkillTerminal())
}}
onRecheck={refreshComputerUseSkill}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME
}
/>
)
}
@@ -163,6 +163,9 @@ export function EphemeralVmsPane(): React.JSX.Element {
: ensureOrcaCliAvailableForAgentSkillTerminal())
}}
onRecheck={refreshSkill}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME
}
/>
<div className="space-y-3 rounded-lg border border-border/60 bg-card/30 p-4">
@@ -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
}
/>
)
@@ -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<void> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<SkillFreshnessCheckButton />)
})
}
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()
})
})
@@ -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 (
<Button
type="button"
variant="outline"
size="sm"
className={className}
onClick={() => requestSkillFreshnessUpdateDialog()}
>
<RefreshCw className="size-3.5" />
{translate(
'auto.components.skills.SkillFreshnessCheckButton.label',
'Check for skill updates'
)}
</Button>
)
}
@@ -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<HTMLDivElement> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<SkillFreshnessStatusPill skillName={skillName} />)
})
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')
})
})
@@ -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 (
<IntegrationStatusPill tone="attention">
{translate(
'auto.components.skills.SkillFreshnessStatusPill.updateAvailable',
'Update available'
)}
</IntegrationStatusPill>
)
}
const placements = inventory?.installations.filter(
(installation) => installation.name === skillName
)
if (
placements &&
placements.length > 0 &&
placements.every((installation) => installation.status === 'current')
) {
return (
<IntegrationStatusPill tone="connected">
{translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')}
</IntegrationStatusPill>
)
}
return (
<IntegrationStatusPill tone="connected">
{translate('auto.components.skills.SkillFreshnessStatusPill.installed', 'Installed')}
</IntegrationStatusPill>
)
}
+4 -2
View File
@@ -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": {
+4 -2
View File
@@ -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": {
+4 -2
View File
@@ -3641,8 +3641,10 @@
"checkNow": "今すぐ確認",
"close": "閉じる"
},
"SkillFreshnessCheckButton": {
"label": "スキルの更新を確認"
"SkillFreshnessStatusPill": {
"updateAvailable": "更新があります",
"upToDate": "最新です",
"installed": "インストール済み"
}
},
"sidebar": {
+4 -2
View File
@@ -3641,8 +3641,10 @@
"checkNow": "지금 확인",
"close": "닫기"
},
"SkillFreshnessCheckButton": {
"label": "스킬 업데이트 확인"
"SkillFreshnessStatusPill": {
"updateAvailable": "업데이트 가능",
"upToDate": "최신 상태",
"installed": "설치됨"
}
},
"sidebar": {
+4 -2
View File
@@ -3641,8 +3641,10 @@
"checkNow": "立即检查",
"close": "关闭"
},
"SkillFreshnessCheckButton": {
"label": "检查技能更新"
"SkillFreshnessStatusPill": {
"updateAvailable": "有可用更新",
"upToDate": "已是最新",
"installed": "已安装"
}
},
"sidebar": {