mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3641,8 +3641,10 @@
|
||||
"checkNow": "今すぐ確認",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"SkillFreshnessCheckButton": {
|
||||
"label": "スキルの更新を確認"
|
||||
"SkillFreshnessStatusPill": {
|
||||
"updateAvailable": "更新があります",
|
||||
"upToDate": "最新です",
|
||||
"installed": "インストール済み"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -3641,8 +3641,10 @@
|
||||
"checkNow": "지금 확인",
|
||||
"close": "닫기"
|
||||
},
|
||||
"SkillFreshnessCheckButton": {
|
||||
"label": "스킬 업데이트 확인"
|
||||
"SkillFreshnessStatusPill": {
|
||||
"updateAvailable": "업데이트 가능",
|
||||
"upToDate": "최신 상태",
|
||||
"installed": "설치됨"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -3641,8 +3641,10 @@
|
||||
"checkNow": "立即检查",
|
||||
"close": "关闭"
|
||||
},
|
||||
"SkillFreshnessCheckButton": {
|
||||
"label": "检查技能更新"
|
||||
"SkillFreshnessStatusPill": {
|
||||
"updateAvailable": "有可用更新",
|
||||
"upToDate": "已是最新",
|
||||
"installed": "已安装"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
Reference in New Issue
Block a user