From 79251d7a9861568dc261faabfa16df5347d1d008 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:08:26 -0700 Subject: [PATCH] [P2] fix(release,settings): restore signing preflight portability, bootstrap diagnostics, and skill re-check (#11692) * fix(release): restore the SignPath composite action when cutting from an older ref Co-authored-by: Orca * fix(startup): record a durable diagnostic before the bootstrap fatal-exit guard exits Co-authored-by: Orca * fix(settings): make agent-skill Re-check rescan skill freshness Co-authored-by: Orca * fix(startup): keep the bootstrap fatal diagnostic when the log override is unwritable Create the parent directory an overridden ORCA_BOOTSTRAP_FATAL_LOG names and fall back to the default location when that path still cannot be opened, so a missing parent no longer costs the only account of the failure. Also pins the Re-check freshness rescan to the completed install scan rather than the click. Co-authored-by: Orca * refactor(settings): move the post-recheck surface sync out of the panel Co-authored-by: Orca * fix(startup): retain diagnostics without node fs * fix(skills): keep freshness scoped to the local runtime * fix(settings): register freshness status translations * fix(settings): scope and sequence skill freshness refreshes * fix(settings): refresh freshness across runtime transitions --------- Co-authored-by: Orca --- .github/workflows/release-cut.yml | 21 +++ build-plugins/bootstrap-fatal-exit-banner.ts | 107 ++++++++++++ .../electron-vite-output-contract.test.ts | 128 ++++++++++++++- ...windows-signing-workflow-contract.test.mjs | 35 ++++ electron.vite.config.ts | 28 +--- .../feature-wall/BrowserUseSkillSetupCard.tsx | 4 +- ...inalOrchestrationDialog.freshness.test.tsx | 143 ++++++++++++++++ .../FloatingTerminalOrchestrationDialog.tsx | 20 ++- .../AgentSkillSetupPanel.freshness.test.tsx | 154 ++++++++++++++++++ .../settings/AgentSkillSetupPanel.test.tsx | 78 ++++++++- .../settings/AgentSkillSetupPanel.tsx | 21 +-- .../components/settings/CliSection.test.tsx | 34 ++++ .../src/components/settings/CliSection.tsx | 4 +- .../settings/ComputerUseSkillSetupPanel.tsx | 2 +- .../components/settings/EphemeralVmsPane.tsx | 2 +- .../settings/LinearAgentSkillPane.test.tsx | 3 +- .../MobileEmulatorAgentControlRow.test.tsx | 59 +++++++ .../MobileEmulatorAgentControlRow.tsx | 6 +- .../components/settings/OrchestrationPane.tsx | 4 +- .../settings/OrchestrationSetupCard.tsx | 2 +- .../src/components/settings/Settings.tsx | 5 +- .../settings/TaskSourceLinearSetup.test.tsx | 3 +- ...nt-skill-installed-command-callers.test.ts | 29 ++++ .../agent-skill-recheck-surface-sync.ts | 19 +++ .../settings/use-linear-agent-skill-setup.ts | 5 +- .../use-local-cli-skill-freshness-name.ts | 10 ++ .../skills/SkillFreshnessNudge.test.tsx | 36 +++- .../components/skills/SkillFreshnessNudge.tsx | 22 ++- .../skills/SkillFreshnessStatusPill.test.tsx | 26 ++- .../skills/SkillFreshnessStatusPill.tsx | 16 +- .../SkillFreshnessUpdateDialog.test.tsx | 4 + .../skills/SkillFreshnessUpdateDialog.tsx | 4 +- .../useActiveProjectSkillRuntime.test.tsx | 22 ++- .../src/hooks/useActiveProjectSkillRuntime.ts | 27 ++- .../src/hooks/useSkillFreshness.test.tsx | 69 +++++++- src/renderer/src/hooks/useSkillFreshness.ts | 66 +++++++- src/renderer/src/i18n/locales/en.json | 4 +- 37 files changed, 1122 insertions(+), 100 deletions(-) create mode 100644 build-plugins/bootstrap-fatal-exit-banner.ts create mode 100644 src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx create mode 100644 src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx create mode 100644 src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx create mode 100644 src/renderer/src/components/settings/agent-skill-recheck-surface-sync.ts create mode 100644 src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 1840514e5..6dba5ac1f 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1015,6 +1015,27 @@ jobs: with: ref: refs/tags/${{ needs.cut.outputs.tag }} + # Why: `uses: ./…` resolves from the checked-out tag, not from the workflow + # ref, so cutting from an older/off-main ref whose tree predates a composite + # action would fail the step with "Can't find 'action.yml'". Restore the + # actions directory from the commit this workflow file itself came from. + - name: Restore composite actions from the workflow ref + if: matrix.platform == 'win' + shell: bash + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + action_path=".github/actions/install-signpath-module/action.yml" + if [ -f "$action_path" ]; then + echo "Composite actions already present at the cut ref." + exit 0 + fi + echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA." + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- .github/actions + test -f "$action_path" + # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - name: Setup pnpm uses: pnpm/action-setup@v6 diff --git a/build-plugins/bootstrap-fatal-exit-banner.ts b/build-plugins/bootstrap-fatal-exit-banner.ts new file mode 100644 index 000000000..de4da1319 --- /dev/null +++ b/build-plugins/bootstrap-fatal-exit-banner.ts @@ -0,0 +1,107 @@ +import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard' +export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG' +export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log' +const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144 + +export function createBootstrapFatalExitBanner(): string { + // Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement. + // Suppressing that dialog also removes the only account of the failure, so record one first: + // a partially copied resources tree otherwise exits silently on every launch, with nothing + // for the user or a support log to name the module that was missing. + return ` +;(() => { + const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)} + if (typeof globalThis[guardKey] === 'function') { + return + } + const describeBootstrapError = (error) => { + try { + const detail = error && typeof error === 'object' && error.stack ? error.stack : error + return String(detail).split('\\n').slice(0, 12).join(' | ').slice(0, 4000) + } catch { + return '' + } + } + const readBootstrapFatalLogOverride = () => { + const override = typeof process.env === 'object' && process.env ? process.env.${BOOTSTRAP_FATAL_LOG_ENV_VAR} : undefined + return typeof override === 'string' && override.length > 0 ? override : undefined + } + const resolveDefaultBootstrapFatalLogPath = () => { + let directory + try { + directory = require('electron').app.getPath('userData') + } catch { + // Why: a bootstrap fault can predate a usable app object; temp still outlives the process. + directory = require('node:os').tmpdir() + } + return require('node:path').join(directory, ${JSON.stringify(BOOTSTRAP_FATAL_LOG_FILE_NAME)}) + } + const appendBootstrapFatalLine = (fs, logPath, entry) => { + try { + // Why: an override can name a directory nothing has created yet, and a missing + // parent would drop the only account of the failure. + fs.mkdirSync(require('node:path').dirname(logPath), { recursive: true }) + // Why: a broken install repeats this on every relaunch; keep the trail bounded. + let flags = 'a' + try { + flags = fs.statSync(logPath).size > ${BOOTSTRAP_FATAL_LOG_MAX_BYTES} ? 'w' : 'a' + } catch { + flags = 'a' + } + const descriptor = fs.openSync(logPath, flags, 0o600) + try { + fs.writeSync(descriptor, entry) + } finally { + fs.closeSync(descriptor) + } + return true + } catch { + return false + } + } + const recordBootstrapFailure = (error) => { + const line = '[bootstrap] fatal-exit pid=' + process.pid + ' error=' + describeBootstrapError(error) + '\\n' + let fs + try { + fs = require('node:fs') + } catch { + fs = undefined + } + try { + fs.writeSync(2, line) + } catch { + try { + process.stderr.write(line) + } catch { + // Diagnostics must never replace the exit below. + } + } + try { + const entry = new Date().toISOString() + ' ' + line + const override = readBootstrapFatalLogOverride() + // Why: an unwritable override must cost the user a location, not the diagnostic. + if (!override || !appendBootstrapFatalLine(fs, override, entry)) { + appendBootstrapFatalLine(fs, resolveDefaultBootstrapFatalLogPath(), entry) + } + } catch { + // Diagnostics must never replace the exit below. + } + } + let exitScheduled = false + const exitAfterBootstrapFailure = (error) => { + if (exitScheduled) { + return + } + exitScheduled = true + recordBootstrapFailure(error) + process.exitCode = 1 + setImmediate(() => process.exit(1)) + } + globalThis[guardKey] = () => { + process.off('uncaughtException', exitAfterBootstrapFailure) + delete globalThis[guardKey] + } + process.once('uncaughtException', exitAfterBootstrapFailure) +})(); +` +} diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 1154ec9cc..931f9b93d 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -1,13 +1,72 @@ -import { readFileSync } from 'node:fs' +import * as nodeFs from 'node:fs' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import * as nodePath from 'node:path' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import { EventEmitter } from 'node:events' import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' -import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config' +import { + BOOTSTRAP_FATAL_LOG_ENV_VAR, + BOOTSTRAP_FATAL_LOG_FILE_NAME, + createBootstrapFatalExitBanner +} from '../../build-plugins/bootstrap-fatal-exit-banner' +import { electronViteConfig } from '../../electron.vite.config' import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard' const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8') const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8') +type BootstrapProcessMock = EventEmitter & { + env: Record + pid: number + exit: (code: number) => void + exitCode?: number +} + +/** Runs the banner in a bare context and raises the bootstrap fault it guards against. */ +function failBootstrapWithBanner(options: { + env: Record + tmpdir?: string + stderrWrites?: string[] +}): BootstrapProcessMock { + const processMock = new EventEmitter() as BootstrapProcessMock + processMock.env = options.env + processMock.pid = 4242 + processMock.exit = () => {} + const fsShim = { + ...nodeFs, + writeSync: (descriptor: number, data: string) => { + if (descriptor === 2) { + options.stderrWrites?.push(data) + return data.length + } + return nodeFs.writeSync(descriptor, data) + } + } + const context = { + process: processMock, + setImmediate: () => {}, + require: (specifier: string) => { + if (specifier === 'node:fs') { + return fsShim + } + if (specifier === 'node:path') { + return nodePath + } + if (specifier === 'node:os' && options.tmpdir !== undefined) { + return { tmpdir: () => options.tmpdir } + } + // Electron's own module is unreachable from a bootstrap fault this early. + throw new Error(`unexpected require: ${specifier}`) + } + } + + runInNewContext(createBootstrapFatalExitBanner(), context) + processMock.emit('uncaughtException', new Error("Cannot find module 'ws'")) + return processMock +} + describe('Electron Vite output contract', () => { it('keeps main-process and plain-Node entries at stable CommonJS paths', () => { const output = electronViteConfig.main?.build?.rollupOptions?.output @@ -40,12 +99,20 @@ describe('Electron Vite output contract', () => { const processMock = new EventEmitter() as EventEmitter & { exit: (code: number) => void exitCode?: number + stderr: { write: (chunk: string) => boolean } } let scheduledExit: (() => void) | null = null let exitedWith: number | null = null + const stderrWrites: string[] = [] processMock.exit = (code) => { exitedWith = code } + processMock.stderr = { + write: (chunk) => { + stderrWrites.push(chunk) + return true + } + } const context = { process: processMock, setImmediate: (callback: () => void) => { @@ -61,6 +128,63 @@ describe('Electron Vite output contract', () => { scheduledExit?.() expect(exitedWith).toBe(1) expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY) + expect(stderrWrites.join('')).toContain("Cannot find module 'zod'") + }) + + it('records the bootstrap failure it exits on, since the guard hides Electron dialog', () => { + const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-')) + const logPath = join(logDirectory, 'fatal.log') + const stderrWrites: string[] = [] + + try { + const processMock = failBootstrapWithBanner({ + env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath }, + stderrWrites + }) + + expect(stderrWrites.join('')).toContain("Cannot find module 'ws'") + const recorded = readFileSync(logPath, 'utf8') + expect(recorded).toContain("Cannot find module 'ws'") + expect(recorded).toContain('pid=4242') + expect(processMock.exitCode).toBe(1) + } finally { + rmSync(logDirectory, { recursive: true, force: true }) + } + }) + + it('creates the parent directory an overridden log path names but does not have', () => { + const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-')) + const logPath = join(logDirectory, 'nested', 'diagnostics', 'fatal.log') + + try { + const processMock = failBootstrapWithBanner({ + env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath } + }) + + expect(readFileSync(logPath, 'utf8')).toContain("Cannot find module 'ws'") + expect(processMock.exitCode).toBe(1) + } finally { + rmSync(logDirectory, { recursive: true, force: true }) + } + }) + + it('falls back to the default location when the overridden log path is unwritable', () => { + const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-')) + const fallbackDirectory = join(logDirectory, 'fallback') + + try { + const processMock = failBootstrapWithBanner({ + // A directory can never be opened as the log file, so the override must yield. + env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logDirectory }, + tmpdir: fallbackDirectory + }) + + const recorded = readFileSync(join(fallbackDirectory, BOOTSTRAP_FATAL_LOG_FILE_NAME), 'utf8') + expect(recorded).toContain("Cannot find module 'ws'") + expect(processMock.exitCode).toBe(1) + } finally { + rmSync(logDirectory, { recursive: true, force: true }) + } }) it('isolates renderer entry side effects behind strict facades', () => { diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs index fc2087379..b5a8f41d0 100644 --- a/config/scripts/windows-signing-workflow-contract.test.mjs +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -104,6 +104,41 @@ describe('Windows signing workflow contract', () => { expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module') }) + it('still installs SignPath when the cut ref predates the composite action', () => { + const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const checkoutIndex = stepNames.indexOf('Checkout') + const restoreIndex = stepNames.indexOf('Restore composite actions from the workflow ref') + const installIndex = stepNames.indexOf('Install SignPath PowerShell module') + + // Why: the build job checks out the cut tag, which for a hotfix cut from an + // older ref can predate `.github/actions/install-signpath-module`; without + // this restore the `uses: ./…` step dies on a missing action.yml. + expect(restoreIndex).toBeGreaterThan(checkoutIndex) + expect(restoreIndex).toBeLessThan(installIndex) + + const restoreStep = steps[restoreIndex] + const restoreRun = restoreStep.run + + expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}') + expect(restoreRun).toContain('.github/actions/install-signpath-module/action.yml') + expect(restoreRun).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"') + expect(restoreRun).toContain('git checkout "$WORKFLOW_SHA" -- .github/actions') + + // Why: restoring the action must not turn signing into a soft dependency — + // a missing module still has to fail the Windows job, and the CDN fallback + // still has to reject an unexpected payload. + expect(steps[installIndex]['continue-on-error']).toBeUndefined() + expect(restoreStep['continue-on-error']).toBeUndefined() + + const installRun = readWorkflow('.github/actions/install-signpath-module/action.yml').runs + .steps[0].run + + expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') + expect(installRun).toContain('throw "SHA-256 mismatch for $source') + }) + it('shares one SignPath module install path between release and rehearsal', () => { const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 957e3f60b..02239421a 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import { defineConfig, type UserConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import { createBootstrapFatalExitBanner } from './build-plugins/bootstrap-fatal-exit-banner' import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard' import packageJson from './package.json' with { type: 'json' } @@ -16,7 +17,6 @@ const BUNDLED_MAIN_DEPENDENCIES = new Set([ const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter( (dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency) ) -const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__' function isExternalMainModule(source: string): boolean { if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) { @@ -171,32 +171,6 @@ function createStartupDiagnosticsBanner(chunkName: string): string { ` } -export function createBootstrapFatalExitBanner(): string { - // Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement. - return ` -;(() => { - const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)} - if (typeof globalThis[guardKey] === 'function') { - return - } - let exitScheduled = false - const exitAfterBootstrapFailure = () => { - if (exitScheduled) { - return - } - exitScheduled = true - process.exitCode = 1 - setImmediate(() => process.exit(1)) - } - globalThis[guardKey] = () => { - process.off('uncaughtException', exitAfterBootstrapFailure) - delete globalThis[guardKey] - } - process.once('uncaughtException', exitAfterBootstrapFailure) -})(); -` -} - function createMainBootstrapPlugin() { return { name: 'orca-main-bootstrap', diff --git a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx index 87e733f76..bac0e6d25 100644 --- a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx +++ b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx @@ -75,10 +75,8 @@ export function BrowserUseSkillSetupCard(props: { onBeforeOpenTerminal={handleBeforeOpenTerminal} showRecheckWhenInstalled={false} onRecheck={skill.refresh} - // Why: the local-host-only freshness scan cannot vouch for a WSL runtime, - // so fall back to the presence-only pill there (mirrors the settings cards). freshnessSkillName={ - activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCA_CLI_SKILL_NAME + activeSkillRuntime.canUseLocalSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined } /> ) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx new file mode 100644 index 000000000..187e161ec --- /dev/null +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx @@ -0,0 +1,143 @@ +// @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 { _skillFreshnessCacheForTests } from '@/hooks/useSkillFreshness' +import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' + +const mocks = vi.hoisted(() => ({ + canUseLocalSkillFreshness: true, + refreshOrchestrationSkill: vi.fn(async () => true), + recordFeatureInteraction: vi.fn() +})) + +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ + agentRuntime: undefined, + discoveryTarget: undefined, + terminalShellOverride: undefined, + installDisabledReason: null, + canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness + }) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'], + useInstalledAgentSkill: () => ({ + installed: true, + loading: false, + error: null, + refresh: mocks.refreshOrchestrationSkill + }), + notifyInstalledAgentSkillsChanged: vi.fn(), + notifyInstalledAgentSkillsRefreshed: vi.fn() +})) + +vi.mock('@/components/settings/CliSkillRuntimeSetup', () => ({ + buildSkillCommandForRuntime: (command: string) => command, + ensureWslCliAvailableForAgentSkillTerminal: vi.fn(), + getWslCliDistroRequest: () => undefined +})) + +vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: () => null +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => undefined, { + getState: () => ({ recordFeatureInteraction: mocks.recordFeatureInteraction }) + }) +})) + +function inventory(eligibleUpdateNames: string[]): SkillFreshnessInventory { + return { schemaVersion: 1, installations: [], eligibleUpdateNames, scanIssues: [], scannedAt: 1 } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function recheckButton(): HTMLButtonElement | undefined { + return Array.from(document.body.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === 'Re-check' + ) +} + +async function renderDialog(): Promise { + await act(async () => { + root?.render( + + ) + }) + await act(async () => {}) +} + +describe('FloatingTerminalOrchestrationDialog freshness', () => { + beforeEach(() => { + _skillFreshnessCacheForTests.reset() + mocks.canUseLocalSkillFreshness = true + mocks.refreshOrchestrationSkill.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + Reflect.deleteProperty(window, 'api') + }) + + it('refreshes the local freshness verdict after presence re-check completes', async () => { + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(['orchestration'])) + .mockResolvedValueOnce(inventory([])) + window.api = { + skills: { freshnessInventory }, + cli: { getInstallStatus: vi.fn().mockResolvedValue({ onPath: true }) } + } as never + + await renderDialog() + expect(freshnessInventory).toHaveBeenCalledOnce() + expect(document.body.textContent).toContain('Update available') + + await act(async () => { + recheckButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await act(async () => {}) + + expect(mocks.refreshOrchestrationSkill).toHaveBeenCalledOnce() + expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(document.body.textContent).not.toContain('Update available') + }) + + it('never reads client freshness for an SSH-owned skill', async () => { + mocks.canUseLocalSkillFreshness = false + const freshnessInventory = vi.fn().mockResolvedValue(inventory([])) + window.api = { + skills: { freshnessInventory }, + cli: { getInstallStatus: vi.fn().mockResolvedValue({ onPath: true }) } + } as never + + await renderDialog() + expect(document.body.textContent).toContain('Installed') + + await act(async () => { + recheckButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await act(async () => {}) + + expect(mocks.refreshOrchestrationSkill).toHaveBeenCalledOnce() + expect(freshnessInventory).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx index 6f9aed3f2..06f87ac38 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx @@ -23,6 +23,7 @@ import { useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { refreshSkillFreshness } from '@/hooks/useSkillFreshness' import { useAppStore } from '@/store' import { buildSkillCommandForRuntime, @@ -74,6 +75,14 @@ export function FloatingTerminalOrchestrationDialog({ } }, [orchestrationSkillDetected, onSetupStateChange]) + const recheckOrchestrationSkill = async (): Promise => { + const installed = await refreshOrchestrationSkill() + if (activeSkillRuntime.canUseLocalSkillFreshness) { + await refreshSkillFreshness() + } + return installed + } + return ( @@ -95,18 +104,15 @@ export function FloatingTerminalOrchestrationDialog({ )} ) : orchestrationSkillDetected ? ( - // Why: the modal owns the status pill, so it must carry the same - // freshness signal — and route to the same review dialog — as the - // settings card for this skill; WSL falls back to presence-only. - activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? ( + activeSkillRuntime.canUseLocalSkillFreshness ? ( + + ) : ( {translate( 'auto.components.floating.terminal.FloatingTerminalOrchestrationDialog.630c0ac8c8', 'Installed' )} - ) : ( - ) ) : ( @@ -161,7 +167,7 @@ export function FloatingTerminalOrchestrationDialog({ ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal()) }} - onRecheck={refreshOrchestrationSkill} + onRecheck={recheckOrchestrationSkill} /> diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx new file mode 100644 index 000000000..ab2fc353b --- /dev/null +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom + +import { act, type ComponentProps } 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 { _skillFreshnessCacheForTests } from '@/hooks/useSkillFreshness' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' + +const mocks = vi.hoisted(() => ({ + skillsChanged: vi.fn(), + skillsRefreshed: vi.fn() +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + notifyInstalledAgentSkillsChanged: mocks.skillsChanged, + notifyInstalledAgentSkillsRefreshed: mocks.skillsRefreshed +})) + +vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: () => null +})) + +function inventory(eligibleUpdateNames: string[]): SkillFreshnessInventory { + return { schemaVersion: 1, installations: [], eligibleUpdateNames, scanIssues: [], scannedAt: 1 } +} + +function panelProps( + onRecheck: () => void | Promise = vi.fn() +): ComponentProps { + return { + title: 'Linear skill', + description: null, + command: 'npx skills add orca-linear --global', + terminalTitle: 'Linear skill setup', + terminalAriaLabel: 'Linear skill install terminal', + terminalWorktreeId: 'settings-linear-skill-terminal', + installed: true, + loading: false, + error: null, + hideHeader: true, + showRecheckWhenInstalled: true, + freshnessSkillName: 'orca-linear', + onRecheck + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +describe('AgentSkillSetupPanel freshness re-check', () => { + beforeEach(() => { + _skillFreshnessCacheForTests.reset() + mocks.skillsChanged.mockReset() + mocks.skillsRefreshed.mockReset() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + Reflect.deleteProperty(window, 'api') + }) + + it('rescans skill freshness and updates the rendered verdict on re-check', async () => { + let completeRecheck: (() => void) | null = null + // Why: a rescan started before the install scan finishes would re-read the same + // pre-update disk state, so the boundary is what the assertions below pin. + const onRecheck = vi.fn( + () => + new Promise((resolve) => { + completeRecheck = resolve + }) + ) + let completeRescan: ((value: SkillFreshnessInventory) => void) | null = null + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(['orca-linear'])) + .mockImplementationOnce( + () => + new Promise((resolve) => { + completeRescan = resolve + }) + ) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + await act(async () => {}) + + expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(container?.textContent).toContain('Update available') + + const recheck = Array.from(container?.querySelectorAll('button') ?? []).find( + (candidate) => candidate.textContent?.trim() === 'Re-check' + ) + expect(recheck).toBeDefined() + + await act(async () => { + recheck?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await act(async () => {}) + + expect(onRecheck).toHaveBeenCalledOnce() + expect(mocks.skillsRefreshed).not.toHaveBeenCalled() + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => { + completeRecheck?.() + }) + await act(async () => {}) + + expect(mocks.skillsRefreshed).toHaveBeenCalledOnce() + expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(container?.textContent).toContain('Checking...') + expect(container?.textContent).not.toContain('Update available') + + await act(async () => { + completeRescan?.(inventory([])) + }) + await act(async () => {}) + + expect(container?.textContent).not.toContain('Checking...') + expect(container?.textContent).not.toContain('Update available') + }) + + it('shows a failed verdict when the post-recheck inventory scan fails', async () => { + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(['orca-linear'])) + .mockRejectedValueOnce(new Error('inventory unavailable')) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + await act(async () => {}) + + const recheck = Array.from(container?.querySelectorAll('button') ?? []).find( + (candidate) => candidate.textContent?.trim() === 'Re-check' + ) + await act(async () => { + recheck?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await act(async () => {}) + + expect(container?.textContent).toContain('Check failed') + expect(container?.textContent).not.toContain('Installed') + expect(container?.textContent).not.toContain('Up to date') + }) +}) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index ac44ed328..158ac81c4 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ toastSuccess: vi.fn(), skillsChanged: vi.fn(), skillsRefreshed: vi.fn(), + freshnessRefresh: vi.fn(), terminalInstanceCount: 0 })) @@ -37,6 +38,10 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ notifyInstalledAgentSkillsRefreshed: mocks.skillsRefreshed })) +vi.mock('@/hooks/useSkillFreshness', () => ({ + refreshSkillFreshness: mocks.freshnessRefresh +})) + vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ OnboardingInlineCommandTerminal: (props: { command: string @@ -155,6 +160,8 @@ describe('AgentSkillSetupPanel', () => { mocks.toastSuccess.mockReset() mocks.skillsChanged.mockReset() mocks.skillsRefreshed.mockReset() + mocks.freshnessRefresh.mockReset() + mocks.freshnessRefresh.mockResolvedValue(undefined) mocks.terminalInstanceCount = 0 Object.defineProperty(window, 'api', { configurable: true, @@ -288,6 +295,50 @@ describe('AgentSkillSetupPanel', () => { expect(mocks.skillsChanged).not.toHaveBeenCalled() }) + it('rechecks presence without invalidating local freshness when no local verdict exists', async () => { + const onRecheck = vi.fn(async () => {}) + await renderInteractivePanel({ onRecheck }) + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(0) + mocks.terminalProps.at(-1)?.onTerminalExit?.() + }) + await act(async () => {}) + + expect(onRecheck).toHaveBeenCalledOnce() + expect(mocks.skillsRefreshed).toHaveBeenCalledOnce() + expect(mocks.skillsChanged).not.toHaveBeenCalled() + expect(mocks.freshnessRefresh).not.toHaveBeenCalled() + }) + + it('refreshes first-install freshness only after the terminal re-check finishes', async () => { + let finishRecheck: (() => void) | null = null + const recheck = new Promise((resolve) => { + finishRecheck = resolve + }) + const onRecheck = vi.fn(() => recheck) + await renderInteractivePanel({ freshnessSkillName: 'orca-cli', onRecheck }) + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onTerminalExit?.() + }) + + expect(onRecheck).toHaveBeenCalledOnce() + expect(mocks.freshnessRefresh).not.toHaveBeenCalled() + expect(mocks.skillsRefreshed).not.toHaveBeenCalled() + + await act(async () => { + finishRecheck?.() + await recheck + }) + + expect(mocks.skillsChanged).not.toHaveBeenCalled() + expect(mocks.skillsRefreshed).toHaveBeenCalledOnce() + expect(mocks.freshnessRefresh).toHaveBeenCalledOnce() + }) + it('opens not-installed setup with the install command for preview, copy, and terminal', async () => { await renderInteractivePanel({ installedCommand: UPDATE_COMMAND }) @@ -499,9 +550,12 @@ describe('AgentSkillSetupPanel', () => { expect(mocks.terminalProps.at(-1)).toMatchObject({ command: UPDATE_COMMAND }) }) - it('invalidates shared skill state before the direct completion re-check', async () => { + it('refreshes shared skill state after the direct completion re-check', async () => { const calls: string[] = [] - mocks.skillsChanged.mockImplementation(() => calls.push('invalidate')) + mocks.skillsRefreshed.mockImplementation(() => calls.push('presence')) + mocks.freshnessRefresh.mockImplementation(async () => { + calls.push('freshness') + }) const onRecheck = vi.fn(() => { calls.push('recheck') }) @@ -513,7 +567,25 @@ describe('AgentSkillSetupPanel', () => { mocks.terminalProps.at(-1)?.onCommandFinished?.(0) }) - expect(calls).toEqual(['invalidate', 'recheck']) + expect(calls).toEqual(['recheck', 'presence', 'freshness']) + }) + + it('rechecks once when command completion is followed by terminal exit', async () => { + const onRecheck = vi.fn() + await renderInteractivePanel({ freshnessSkillName: 'orca-cli', onRecheck }) + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(0) + }) + await act(async () => { + mocks.terminalProps.at(-1)?.onTerminalExit?.() + }) + await act(async () => {}) + + expect(onRecheck).toHaveBeenCalledOnce() + expect(mocks.skillsRefreshed).toHaveBeenCalledOnce() + expect(mocks.freshnessRefresh).toHaveBeenCalledOnce() }) it('re-enables Install after the setup shell exits so a failed attempt can retry', async () => { diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index c141b9bf4..ee021d969 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -8,11 +8,11 @@ import { AgentSkillSetupFailureNotice } from './AgentSkillSetupFailureNotice' import type { AgentSkillSetupPanelProps } from './agent-skill-setup-panel-props' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' -import { - notifyInstalledAgentSkillsChanged, - notifyInstalledAgentSkillsRefreshed -} from '@/hooks/useInstalledAgentSkills' import { useMountedRef } from '@/hooks/useMountedRef' +import { + recheckSurfacesAfterAgentSkillTerminal, + syncSurfacesAfterAgentSkillRecheck +} from './agent-skill-recheck-surface-sync' import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -122,22 +122,20 @@ export function AgentSkillSetupPanel({ if (bestEffortExitCode !== null) { setSetupCommandFailedCode(bestEffortExitCode === 0 ? null : bestEffortExitCode) } - if (freshnessSkillName) { - notifyInstalledAgentSkillsChanged() - } - void onRecheck() + recheckSurfacesAfterAgentSkillTerminal(onRecheck, freshnessSkillName) }, [freshnessSkillName, onRecheck] ) const handleTerminalExit = useCallback((): void => { + const shouldRecheck = setupAttemptRunningRef.current if (mountedRef.current) { setupAttemptRunningRef.current = false setTerminalOpen(false) setSetupAttemptRunning(false) } - notifyInstalledAgentSkillsChanged() - }, [mountedRef]) + void (shouldRecheck && recheckSurfacesAfterAgentSkillTerminal(onRecheck, freshnessSkillName)) + }, [freshnessSkillName, mountedRef, onRecheck]) useEffect(() => { if (!preInstallNotice) { @@ -235,8 +233,7 @@ export function AgentSkillSetupPanel({ return } void Promise.resolve(onRecheck()).then(() => { - // Reuse the completed scan so sibling surfaces sync without rediscovery. - notifyInstalledAgentSkillsRefreshed() + syncSurfacesAfterAgentSkillRecheck(freshnessSkillName) }) }} disabled={ diff --git a/src/renderer/src/components/settings/CliSection.test.tsx b/src/renderer/src/components/settings/CliSection.test.tsx index 7db93106f..20830a2fe 100644 --- a/src/renderer/src/components/settings/CliSection.test.tsx +++ b/src/renderer/src/components/settings/CliSection.test.tsx @@ -7,9 +7,11 @@ import { getDefaultSettings } from '../../../../shared/constants' import { CliSection } from './CliSection' const capturedPanel = vi.hoisted(() => ({ + canUseLocalSkillFreshness: true, props: null as null | { command: string installedCommand: string + freshnessSkillName?: string getPrerequisiteStatus: () => Promise onBeforeOpenTerminal: () => Promise }, @@ -24,6 +26,12 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ useInstalledAgentSkill: capturedPanel.useInstalledAgentSkill })) +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ + canUseLocalSkillFreshness: capturedPanel.canUseLocalSkillFreshness + }) +})) + capturedPanel.useInstalledAgentSkill.mockReturnValue({ installed: false, loading: false, @@ -33,6 +41,7 @@ capturedPanel.useInstalledAgentSkill.mockReturnValue({ afterEach(() => { cleanup() + capturedPanel.canUseLocalSkillFreshness = true toastError.mockReset() vi.unstubAllGlobals() }) @@ -41,6 +50,7 @@ vi.mock('./AgentSkillSetupPanel', () => ({ AgentSkillSetupPanel: function AgentSkillSetupPanel(props: { command: string installedCommand: string + freshnessSkillName?: string getPrerequisiteStatus: () => Promise onBeforeOpenTerminal: () => Promise }) { @@ -62,6 +72,30 @@ vi.mock('./WslCliRegistration', () => ({ })) describe('CliSection project runtime defaults', () => { + it('exposes freshness only for a resolved local host runtime', () => { + const settings = getDefaultSettings('/tmp') + renderToStaticMarkup() + expect(capturedPanel.props?.freshnessSkillName).toBe('orca-cli') + + capturedPanel.canUseLocalSkillFreshness = false + renderToStaticMarkup() + expect(capturedPanel.props?.freshnessSkillName).toBeUndefined() + + capturedPanel.canUseLocalSkillFreshness = true + renderToStaticMarkup( + + ) + expect(capturedPanel.props?.freshnessSkillName).toBeUndefined() + }) + it('passes the default project WSL distro to CLI skill prerequisite checks', async () => { const getWslInstallStatus = vi .fn() diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 6f6eab2ee..41984fe20 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -32,6 +32,7 @@ import { getWslCliDistroRequest } from './CliSkillRuntimeSetup' import { WslCliRegistration } from './WslCliRegistration' +import { useLocalCliSkillFreshnessName } from './use-local-cli-skill-freshness-name' import { translate } from '@/i18n/i18n' type CliSectionProps = { @@ -86,6 +87,7 @@ export function CliSection({ getSelectedAgentRuntime(settings, wslSupportedPlatform, wslAvailable, wslCapabilitiesLoading), [settings, wslAvailable, wslCapabilitiesLoading, wslSupportedPlatform] ) + const cliSkillFreshnessName = useLocalCliSkillFreshnessName(agentRuntime) const cliSkillDiscoveryTarget = useMemo( () => getSkillDiscoveryTargetForRuntime(agentRuntime), [agentRuntime] @@ -395,7 +397,7 @@ export function CliSection({ })) }} onRecheck={refreshCliSkill} - freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined} + freshnessSkillName={cliSkillFreshnessName} /> ) : null} diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 0ffc0a2b5..e916686c2 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -80,7 +80,7 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { }} onRecheck={refreshComputerUseSkill} freshnessSkillName={ - activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME + activeSkillRuntime.canUseLocalSkillFreshness ? COMPUTER_USE_SKILL_NAME : undefined } /> ) diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index e2fb92278..06c8fe027 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -183,7 +183,7 @@ export function EphemeralVmsPane(): React.JSX.Element { }} onRecheck={refreshSkill} freshnessSkillName={ - activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME + activeSkillRuntime.canUseLocalSkillFreshness ? EPHEMERAL_VMS_SKILL_NAME : undefined } /> diff --git a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx index e8e0dd442..79e4f641b 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx @@ -69,7 +69,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ discoveryTarget: undefined, agentRuntime: { runtime: mocks.runtime }, terminalShellOverride: undefined, - installDisabledReason: null + installDisabledReason: null, + canUseLocalSkillFreshness: mocks.runtime !== 'wsl' }) })) diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx new file mode 100644 index 000000000..668d0d1fd --- /dev/null +++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx @@ -0,0 +1,59 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileEmulatorAgentControlRow } from './MobileEmulatorAgentControlRow' + +const mocks = vi.hoisted(() => ({ + canUseLocalSkillFreshness: true, + freshnessSkillName: undefined as string | undefined +})) + +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ + canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness, + terminalShellOverride: undefined + }) +})) + +vi.mock('../emulator-pane/use-mobile-emulator-agent-setup-state', () => ({ + useMobileEmulatorAgentSetupState: () => ({ + cliActionLabel: 'Enable', + cliBusy: false, + cliEnabled: true, + cliInstallStatus: null, + cliLoading: false, + cliSkillError: null, + cliSkillInstalled: true, + cliSkillLoading: false, + cliSupported: true, + completedCount: 2, + handleEnableCli: vi.fn(), + refreshCliSkill: vi.fn(), + step2Blocked: false + }) +})) + +vi.mock('./AgentSkillSetupPanel', () => ({ + AgentSkillSetupPanel: ({ freshnessSkillName }: { freshnessSkillName?: string }) => { + mocks.freshnessSkillName = freshnessSkillName + return null + } +})) + +vi.mock('./SetupStepBadge', () => ({ StepBadge: () => null })) +vi.mock('./MobileEmulatorExamples', () => ({ MobileEmulatorExamples: () => null })) + +describe('MobileEmulatorAgentControlRow freshness authority', () => { + beforeEach(() => { + mocks.canUseLocalSkillFreshness = true + mocks.freshnessSkillName = undefined + }) + + it('exposes local freshness only for a resolved local non-WSL runtime', () => { + renderToStaticMarkup() + expect(mocks.freshnessSkillName).toBe('orca-cli') + + mocks.canUseLocalSkillFreshness = false + renderToStaticMarkup() + expect(mocks.freshnessSkillName).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx index 4aaad31fb..872f530fe 100644 --- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx @@ -177,9 +177,9 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element { await ensureOrcaCliAvailableForAgentSkillTerminal() }} onRecheck={setup.refreshCliSkill} - // Why: this row builds its commands for the local host only, so the - // local-host freshness scan can vouch for the copy it points at. - freshnessSkillName={ORCA_CLI_SKILL_NAME} + freshnessSkillName={ + activeSkillRuntime.canUseLocalSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined + } /> diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index 66fe68aa0..09910c865 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -156,10 +156,8 @@ export function OrchestrationPane(): React.JSX.Element { /> } onRecheck={refreshOrchestrationSkill} - // Why: the local-host-only freshness scan cannot vouch for a WSL runtime, - // so fall back to the presence-only pill there (mirrors the Computer Use card). freshnessSkillName={ - activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME + activeSkillRuntime.canUseLocalSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined } /> diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index d597895d5..b47b65a0c 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -77,7 +77,7 @@ export function OrchestrationSetupCard(props: { }} onRecheck={skill.refresh} freshnessSkillName={ - activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME + activeSkillRuntime.canUseLocalSkillFreshness ? ORCHESTRATION_SKILL_NAME : undefined } /> ) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 1cede19ac..71db4c822 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -351,9 +351,8 @@ function Settings(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - // Why: skill freshness only covers the validated global rail (not WSL), so the nav pill stays presence-only under WSL. - const { inventory: skillFreshnessInventory } = useSkillFreshness() - const skillFreshnessApplies = activeSkillRuntime.agentRuntime?.runtime !== 'wsl' + const skillFreshnessApplies = activeSkillRuntime.canUseLocalSkillFreshness + const { inventory: skillFreshnessInventory } = useSkillFreshness(skillFreshnessApplies) const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings) // Why: trim platform-only Terminal entries from the shared search index so search never reveals hidden controls. const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset') diff --git a/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx b/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx index 878bef812..938c788a6 100644 --- a/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx +++ b/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx @@ -48,7 +48,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ discoveryTarget: undefined, agentRuntime: { runtime: 'native' }, terminalShellOverride: undefined, - installDisabledReason: null + installDisabledReason: null, + canUseLocalSkillFreshness: true }) })) 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 9f041ea07..f0bab20bf 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 @@ -180,6 +180,35 @@ describe('AgentSkillSetupPanel installed-command call sites', () => { ) }) + it('keeps client freshness behind resolved local runtime authority', () => { + const expectedGates = new Map([ + [ + 'src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts', + "agentRuntime.runtime === 'host' && activeSkillRuntime.canUseLocalSkillFreshness" + ], + [ + 'src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx', + 'activeSkillRuntime.canUseLocalSkillFreshness ? ORCA_CLI_SKILL_NAME : undefined' + ], + [ + 'src/renderer/src/components/settings/Settings.tsx', + 'useSkillFreshness(skillFreshnessApplies)' + ], + [ + 'src/renderer/src/components/skills/SkillFreshnessNudge.tsx', + 'useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness)' + ], + [ + 'src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx', + 'useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness)' + ] + ]) + + for (const [relativePath, expectedGate] of expectedGates) { + expect(readRepoFile(relativePath), relativePath).toContain(expectedGate) + } + }) + it('fails when a production caller can show the default Update action without installedCommand', () => { const productionCallers = findProductionPanelCallers(componentsRoot) diff --git a/src/renderer/src/components/settings/agent-skill-recheck-surface-sync.ts b/src/renderer/src/components/settings/agent-skill-recheck-surface-sync.ts new file mode 100644 index 000000000..ec4aeae31 --- /dev/null +++ b/src/renderer/src/components/settings/agent-skill-recheck-surface-sync.ts @@ -0,0 +1,19 @@ +import { notifyInstalledAgentSkillsRefreshed } from '@/hooks/useInstalledAgentSkills' +import { refreshSkillFreshness } from '@/hooks/useSkillFreshness' + +/** Publishes a completed re-check to presence and optional local-freshness surfaces. */ +export function syncSurfacesAfterAgentSkillRecheck(freshnessSkillName?: string): void { + notifyInstalledAgentSkillsRefreshed() + if (freshnessSkillName) { + void refreshSkillFreshness() + } +} + +export function recheckSurfacesAfterAgentSkillTerminal( + onRecheck: () => void | Promise, + freshnessSkillName?: string +): void { + void Promise.resolve(onRecheck()).then(() => { + syncSurfacesAfterAgentSkillRecheck(freshnessSkillName) + }) +} diff --git a/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts index 21a378a1b..2fd89c87c 100644 --- a/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts +++ b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts @@ -67,8 +67,9 @@ export function useLinearAgentSkillSetup(): { : buildSkillCommandForRuntime(updateTarget.command, activeSkillRuntime.agentRuntime) // Freshness cannot verify WSL, so report presence there. - const freshnessSkillName = - activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : updateTarget.skillName + const freshnessSkillName = activeSkillRuntime.canUseLocalSkillFreshness + ? updateTarget.skillName + : undefined const getPrerequisiteStatus = useCallback( () => diff --git a/src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts b/src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts new file mode 100644 index 000000000..a1475a2fd --- /dev/null +++ b/src/renderer/src/components/settings/use-local-cli-skill-freshness-name.ts @@ -0,0 +1,10 @@ +import { ORCA_CLI_SKILL_NAME } from '@/lib/agent-feature-install-commands' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import type { LocalAgentRuntime } from './CliSkillRuntimeSetup' + +export function useLocalCliSkillFreshnessName(agentRuntime: LocalAgentRuntime): string | undefined { + const activeSkillRuntime = useActiveProjectSkillRuntime() + return agentRuntime.runtime === 'host' && activeSkillRuntime.canUseLocalSkillFreshness + ? ORCA_CLI_SKILL_NAME + : undefined +} diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx index d32f6a017..52564bb02 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({ toastDismiss: vi.fn(), requestDialog: vi.fn(), settingsLoaded: true, + canUseLocalSkillFreshness: true, + freshnessEnabled: true, inventory: null as SkillFreshnessInventory | null, error: null as string | null })) @@ -57,11 +59,20 @@ function eligibleInventory(): SkillFreshnessInventory { } vi.mock('@/hooks/useSkillFreshness', () => ({ - useSkillFreshness: () => ({ - inventory: mocks.inventory, - loading: false, - error: mocks.error, - refresh: vi.fn() + useSkillFreshness: (enabled = true) => { + mocks.freshnessEnabled = enabled + return { + inventory: mocks.inventory, + loading: false, + error: mocks.error, + refresh: vi.fn() + } + } +})) + +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ + canUseLocalSkillFreshness: mocks.canUseLocalSkillFreshness }) })) @@ -107,6 +118,8 @@ describe('SkillFreshnessNudge', () => { beforeEach(() => { mocks.dismissed = [] mocks.settingsLoaded = true + mocks.canUseLocalSkillFreshness = true + mocks.freshnessEnabled = true mocks.inventory = eligibleInventory() mocks.error = null mocks.updateSettings.mockReset() @@ -179,6 +192,19 @@ describe('SkillFreshnessNudge', () => { expect(mocks.updateSettings).not.toHaveBeenCalled() }) + it('retracts an active local nudge when runtime freshness becomes unavailable', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.canUseLocalSkillFreshness = false + await rerenderNudge() + options.onDismiss() + + expect(mocks.freshnessEnabled).toBe(false) + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + it('persists the exact placement/revision key once on explicit dismissal', async () => { await renderNudge() diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx index eb2962981..7e6e8cebc 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { Terminal } from 'lucide-react' import { toast } from 'sonner' import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { skillPlacementParticipatesInGlobalFreshness } from '../../../../shared/skill-freshness' @@ -25,7 +26,8 @@ function candidateKey(args: { } export function SkillFreshnessNudge(): null { - const state = useSkillFreshness() + const activeSkillRuntime = useActiveProjectSkillRuntime() + const state = useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness) const settingsLoaded = useAppStore((store) => store.settings !== null) const dismissed = useAppStore( (store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES @@ -36,6 +38,15 @@ export function SkillFreshnessNudge(): null { const activeNudgeRef = useRef(null) useEffect(() => { + if (!activeSkillRuntime.canUseLocalSkillFreshness) { + const active = activeNudgeRef.current + if (active) { + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } const inventory = state.inventory if (!settingsLoaded) { return @@ -182,7 +193,14 @@ export function SkillFreshnessNudge(): null { } ) activeNudgeRef.current = nextActive - }, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings]) + }, [ + activeSkillRuntime.canUseLocalSkillFreshness, + dismissed, + settingsLoaded, + state.error, + state.inventory, + updateSettings + ]) return null } diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx index 24c08ee1d..a2719cd41 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx @@ -8,14 +8,16 @@ import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill' import { consumeSkillFreshnessUpdateDialogRequest } from './skill-freshness-update-dialog' const mocks = vi.hoisted(() => ({ - inventory: null as SkillFreshnessInventory | null + inventory: null as SkillFreshnessInventory | null, + loading: false, + error: null as string | null })) vi.mock('@/hooks/useSkillFreshness', () => ({ useSkillFreshness: () => ({ inventory: mocks.inventory, - loading: false, - error: null, + loading: mocks.loading, + error: mocks.error, refresh: vi.fn() }) })) @@ -76,6 +78,8 @@ async function renderPill(skillName: string): Promise { describe('SkillFreshnessStatusPill', () => { beforeEach(() => { mocks.inventory = null + mocks.loading = false + mocks.error = null // Why: the dialog request is module-level state shared across tests. consumeSkillFreshnessUpdateDialogRequest() }) @@ -127,6 +131,22 @@ describe('SkillFreshnessStatusPill', () => { expect(detailsButton(rendered)).toBeNull() }) + it('shows a neutral verdict while freshness is being checked', async () => { + mocks.loading = true + + const rendered = await renderPill('orca-cli') + expect(pillText(rendered)).toBe('Checking...') + expect(detailsButton(rendered)).toBeNull() + }) + + it('does not report success after a freshness check fails', async () => { + mocks.error = 'Could not read skills.' + + const rendered = await renderPill('orca-cli') + expect(pillText(rendered)).toBe('Check failed') + expect(detailsButton(rendered)).toBeNull() + }) + it('opens the freshness review dialog from Details', async () => { mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli']) const rendered = await renderPill('orca-cli') diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx index bedcfa4f3..73c84f560 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -51,7 +51,21 @@ function statusPill(status: SkillFreshnessDisplayStatus): React.JSX.Element { // somewhere the update cannot reach — and green must never stand in for that last // case, which is real drift the user would otherwise have no way to see. export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { - const { inventory } = useSkillFreshness() + const { inventory, loading, error } = useSkillFreshness() + if (loading && !inventory) { + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.checking', 'Checking...')} + + ) + } + if (error && !inventory) { + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.checkFailed', 'Check failed')} + + ) + } const status = getSkillFreshnessDisplayStatus(inventory, skillName) // Why: the dialog lists every placement, so Details is offered whenever a placement // is what drove the status — an available update, or a copy that blocked one. diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index 1a53ecdcf..f652a2ad1 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -32,6 +32,10 @@ vi.mock('@/hooks/useSkillFreshness', () => ({ }) })) +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ canUseLocalSkillFreshness: true }) +})) + vi.mock('@/hooks/useInstalledAgentSkills', () => ({ notifyInstalledAgentSkillsChanged: mocks.notifyChanged })) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx index 8dd89233b..26edc09cf 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -7,6 +7,7 @@ import { type SkillFreshnessInventory } from '../../../../shared/skill-freshness' import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' import { Button } from '@/components/ui/button' @@ -63,7 +64,8 @@ function RunLog({ output }: { output: string }): React.JSX.Element | null { } export function SkillFreshnessUpdateDialog(): React.JSX.Element { - const state = useSkillFreshness() + const activeSkillRuntime = useActiveProjectSkillRuntime() + const state = useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness) const run = useSkillUpdateRun() const open = useSyncExternalStore( subscribeSkillFreshnessUpdateDialog, diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx index dc19e333d..71a725767 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx @@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { renderHook } from '@testing-library/react' import { getDefaultSettings } from '../../../shared/constants' import { useAppStore } from '@/store' -import { useActiveProjectSkillRuntime } from './useActiveProjectSkillRuntime' +import { + shouldUseLocalSkillFreshness, + useActiveProjectSkillRuntime +} from './useActiveProjectSkillRuntime' function setPlatform(platform: NodeJS.Platform): void { ;(window as unknown as { api: unknown }).api = { @@ -42,4 +45,21 @@ describe('useActiveProjectSkillRuntime', () => { expect(result.current.terminalShellOverride).toBeUndefined() }) + + it('limits local freshness to resolved host runtimes', () => { + expect(shouldUseLocalSkillFreshness({ kind: 'local' }, undefined)).toBe(true) + expect( + shouldUseLocalSkillFreshness({ kind: 'local' }, { runtime: 'host', label: 'Host' }) + ).toBe(true) + expect(shouldUseLocalSkillFreshness({ kind: 'local' }, { runtime: 'wsl', label: 'WSL' })).toBe( + false + ) + expect( + shouldUseLocalSkillFreshness( + { kind: 'environment', environmentId: 'ssh-production' }, + undefined + ) + ).toBe(false) + expect(shouldUseLocalSkillFreshness(null, undefined)).toBe(false) + }) }) diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts index 9c78fa81e..1a6df80b4 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -2,6 +2,8 @@ import { useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import type { SkillDiscoveryTarget } from '../../../shared/skills' +import { useActiveSkillDiscoveryRuntimeTarget } from './use-active-skill-discovery-runtime-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getProjectAgentSkillRuntime, @@ -19,12 +21,21 @@ type ActiveProjectSkillRuntime = { agentRuntime?: ProjectAgentSkillRuntime terminalShellOverride?: string installDisabledReason: string | null + canUseLocalSkillFreshness: boolean } const EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME: ActiveProjectSkillRuntime = Object.freeze({ - installDisabledReason: null + installDisabledReason: null, + canUseLocalSkillFreshness: false }) +export function shouldUseLocalSkillFreshness( + runtimeTarget: RuntimeClientTarget | null, + agentRuntime?: ProjectAgentSkillRuntime +): boolean { + return runtimeTarget?.kind === 'local' && agentRuntime?.runtime !== 'wsl' +} + // Why: on Windows the runtime resolution is rebuilt from scratch on every // worktree-store change, so a same-runtime result still arrives with a fresh // identity. Downstream skill discovery keys effects off `discoveryTarget`, so @@ -48,6 +59,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { ) const currentPlatform = getCurrentPlatform() const windowsCapabilities = useWindowsTerminalCapabilities(currentPlatform === 'win32') + const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget() const resolved = useMemo(() => { const projectRuntime = getLocalProjectExecutionRuntimeContext( @@ -67,9 +79,11 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { runtimeState.settings, undefined ) - return terminalShellOverride - ? { installDisabledReason: null, terminalShellOverride } - : EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME + const canUseLocalSkillFreshness = shouldUseLocalSkillFreshness(runtimeTarget) + if (!terminalShellOverride && !canUseLocalSkillFreshness) { + return EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME + } + return { installDisabledReason: null, terminalShellOverride, canUseLocalSkillFreshness } } const agentRuntime = getProjectAgentSkillRuntime(projectRuntime, currentPlatform) @@ -82,9 +96,10 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { runtimeState.settings, agentRuntime ), - installDisabledReason: getProjectSkillInstallDisabledReason(projectRuntime) + installDisabledReason: getProjectSkillInstallDisabledReason(projectRuntime), + canUseLocalSkillFreshness: shouldUseLocalSkillFreshness(runtimeTarget, agentRuntime) } - }, [currentPlatform, runtimeState, windowsCapabilities]) + }, [currentPlatform, runtimeState, runtimeTarget, windowsCapabilities]) // Content-equal runtimes keep one reference so effect keys do not thrash. // Adjust during render (not a ref write) when serialized identity changes. diff --git a/src/renderer/src/hooks/useSkillFreshness.test.tsx b/src/renderer/src/hooks/useSkillFreshness.test.tsx index 09c2d6863..e5fc74a1f 100644 --- a/src/renderer/src/hooks/useSkillFreshness.test.tsx +++ b/src/renderer/src/hooks/useSkillFreshness.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act } from 'react' +import { act, StrictMode } 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' @@ -32,10 +32,12 @@ let root: Root | null = null let container: HTMLDivElement | null = null let state: SkillFreshnessState | null = null const states = new Map() +const renderedInventories: (SkillFreshnessInventory | null)[] = [] -function Probe({ id = 'default' }: { id?: string }): null { - state = useSkillFreshness() +function Probe({ id = 'default', enabled = true }: { id?: string; enabled?: boolean }): null { + state = useSkillFreshness(enabled) states.set(id, state) + renderedInventories.push(state.inventory) return null } @@ -44,6 +46,7 @@ describe('useSkillFreshness', () => { _skillFreshnessCacheForTests.reset() state = null states.clear() + renderedInventories.length = 0 container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -51,6 +54,7 @@ describe('useSkillFreshness', () => { afterEach(async () => { vi.useRealTimers() + vi.restoreAllMocks() if (root) { await act(async () => root?.unmount()) } @@ -79,6 +83,65 @@ describe('useSkillFreshness', () => { expect(state?.inventory?.scannedAt).toBe(2) }) + it('does not scan, subscribe, or refresh while local freshness is disabled', async () => { + const freshnessInventory = vi.fn().mockResolvedValue(inventory(1)) + const addEventListener = vi.spyOn(window, 'addEventListener') + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => state?.refresh()) + + expect(freshnessInventory).not.toHaveBeenCalled() + expect(state).toMatchObject({ inventory: null, loading: false, error: null }) + expect( + addEventListener.mock.calls.filter( + ([name]) => name === 'focus' || name === 'orca:installed-agent-skills-changed' + ) + ).toHaveLength(0) + }) + + it('forces one fresh scan before restoring authority after re-enable', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1, ['orca-cli'])) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + const renderProbes = (enabled: boolean): void => { + root?.render( + + + + + ) + } + + await act(async () => renderProbes(true)) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli']) + + await act(async () => renderProbes(true)) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => renderProbes(false)) + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(state).toMatchObject({ inventory: null, loading: false }) + + const reenableRenderStart = renderedInventories.length + await act(async () => renderProbes(true)) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(state).toMatchObject({ inventory: null, loading: true }) + expect(renderedInventories.slice(reenableRenderStart).every((value) => value === null)).toBe( + true + ) + + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + expect(state?.inventory?.eligibleUpdateNames).toEqual([]) + }) + it('skips focus rescans inside the cooldown but honors install-change events', async () => { const first = deferred() const second = deferred() diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts index 91ea0c4bd..568ad67b3 100644 --- a/src/renderer/src/hooks/useSkillFreshness.ts +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -1,4 +1,4 @@ -import { useEffect, useSyncExternalStore } from 'react' +import { useEffect, useRef, useSyncExternalStore } from 'react' import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' @@ -24,7 +24,18 @@ let snapshot: SkillFreshnessSnapshot = { loading: false, error: null } +const DISABLED_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({ + inventory: null, + loading: false, + error: null +}) +const REENABLING_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({ + inventory: null, + loading: true, + error: null +}) const subscribers = new Set<() => void>() +let pendingReenableRefresh: Promise | null = null function publishSnapshot(next: SkillFreshnessSnapshot): void { if ( @@ -70,7 +81,10 @@ async function loadInventory(force: boolean): Promise { } } -async function refreshSkillFreshness(force = true): Promise { +/** Rescan freshness without going through the hook, for surfaces whose explicit + * refresh affordance must move the shared verdict (the installed-skills refreshed + * event reuses a completed scan and deliberately does not reach this store). */ +export async function refreshSkillFreshness(force = true): Promise { if (scheduledFocusRescan !== null) { window.clearTimeout(scheduledFocusRescan) scheduledFocusRescan = null @@ -133,14 +147,26 @@ function subscribe(subscriber: () => void): () => void { if (subscribers.size === 0) { window.removeEventListener('focus', onWindowFocus) window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } } } } +function subscribeDisabled(): () => void { + return () => {} +} + function getSnapshot(): SkillFreshnessSnapshot { return snapshot } +function getDisabledSnapshot(): SkillFreshnessSnapshot { + return DISABLED_SNAPSHOT +} + function ensureInventoryLoaded(): void { if (!snapshot.inventory && !snapshot.loading) { void refreshSkillFreshness(false) @@ -151,14 +177,41 @@ export type SkillFreshnessState = SkillFreshnessSnapshot & { refresh: () => Promise } -export function useSkillFreshness(): SkillFreshnessState { - const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +async function skipSkillFreshnessRefresh(): Promise {} + +function refreshSkillFreshnessAfterReenable(): Promise { + pendingReenableRefresh ??= refreshSkillFreshness(true).finally(() => { + pendingReenableRefresh = null + }) + return pendingReenableRefresh +} + +export function useSkillFreshness(enabled = true): SkillFreshnessState { + const previousEnabledRef = useRef(enabled) + const reenabled = enabled && !previousEnabledRef.current + const current = useSyncExternalStore( + enabled ? subscribe : subscribeDisabled, + enabled ? getSnapshot : getDisabledSnapshot, + enabled ? getSnapshot : getDisabledSnapshot + ) useEffect(() => { + const wasEnabled = previousEnabledRef.current + previousEnabledRef.current = enabled + if (!enabled) { + return + } + if (!wasEnabled) { + void refreshSkillFreshnessAfterReenable() + return + } ensureInventoryLoaded() - }, []) + }, [enabled]) - return { ...current, refresh: refreshSkillFreshness } + return { + ...(reenabled ? REENABLING_SNAPSHOT : current), + refresh: enabled ? refreshSkillFreshness : skipSkillFreshnessRefresh + } } export const _skillFreshnessCacheForTests = { @@ -169,6 +222,7 @@ export const _skillFreshnessCacheForTests = { completedRevision = -1 lastCompletedScanAt = 0 refreshSequence = 0 + pendingReenableRefresh = null if (scheduledFocusRescan !== null) { window.clearTimeout(scheduledFocusRescan) scheduledFocusRescan = null diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7283f862c..98b6ff3d6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3929,7 +3929,9 @@ "upToDate": "Up to date", "installed": "Installed", "details": "Details", - "needsAttention": "Review skill" + "needsAttention": "Review skill", + "checking": "Checking...", + "checkFailed": "Check failed" } }, "sidebar": {