From deb2b50e71280f6b2ae84886fc32be326e448a82 Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:25:12 -0700 Subject: [PATCH] fix(sidebar): revalidate setup-script prompt when the hook becomes effective (#8752) (#8893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Add a setup script" card cached its inspection result and only re-ran on activeRepo/settings/sidebarOpen/dismiss/retry changes. A shared orca.yaml setup hook that became effective on disk — edited externally, or run during worktree creation — left the stale prompt visible until a full sidebar reopen. Extract the revalidation into useSetupScriptPromptRevalidation, which re-inspects on window focus (external edits / terminal hook runs) and when a worktree of the repo activates while the prompt still shows no effective setup. Claude-Session: https://claude.ai/code/session_01C8RPZ1mhUCMWcojgD6jLaN Co-authored-by: kaynan --- .../sidebar/SetupScriptPromptCard.tsx | 9 + .../useSetupScriptPromptRevalidation.test.tsx | 166 ++++++++++++++++++ .../useSetupScriptPromptRevalidation.ts | 61 +++++++ 3 files changed, 236 insertions(+) create mode 100644 src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx create mode 100644 src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index 3631fd81c..7240958b7 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -26,6 +26,7 @@ import { type LastVisibleSetupScriptPrompt, useSetupScriptPromptProjectContext } from './setup-script-prompt-render-state' +import { useSetupScriptPromptRevalidation } from './useSetupScriptPromptRevalidation' import { translate } from '@/i18n/i18n' type PromptState = SetupScriptPromptInspection @@ -113,6 +114,14 @@ function SetupScriptPromptCard(): React.JSX.Element | null { setInspectionRetryKey((value) => value + 1) }, []) + useSetupScriptPromptRevalidation({ + activeRepo, + isDismissed, + sidebarOpen, + promptState, + requestRevalidation: handleRetryInspection + }) + useEffect(() => { if ( !sidebarOpen || diff --git a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx new file mode 100644 index 000000000..4d7d09bbd --- /dev/null +++ b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx @@ -0,0 +1,166 @@ +// @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 { useAppStore } from '@/store' +import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' +import type { Repo } from '../../../../shared/types' +import { useSetupScriptPromptRevalidation } from './useSetupScriptPromptRevalidation' + +const GIT_REPO = { id: 'repo-1', kind: 'git' } as unknown as Repo + +function missingSetup(repoId: string): SetupScriptPromptInspection { + return { status: 'ok', repoId, hasEffectiveSetup: false, hasSharedHooks: true, candidate: null } +} + +function effectiveSetup(repoId: string): SetupScriptPromptInspection { + return { status: 'ok', repoId, hasEffectiveSetup: true, hasSharedHooks: true, candidate: null } +} + +type HarnessProps = { + activeRepo: Repo | null + isDismissed: boolean + sidebarOpen: boolean + promptState: SetupScriptPromptInspection | null + requestRevalidation: () => void +} + +function Harness(props: HarnessProps): null { + useSetupScriptPromptRevalidation(props) + return null +} + +const roots: Root[] = [] + +async function render(props: HarnessProps): Promise<(next: HarnessProps) => Promise> { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) + return async (next: HarnessProps) => { + await act(async () => { + root.render() + }) + } +} + +async function dispatchWindowFocus(): Promise { + await act(async () => { + window.dispatchEvent(new Event('focus')) + }) +} + +async function setActiveWorktree(worktreeId: string | null): Promise { + await act(async () => { + useAppStore.setState({ activeWorktreeId: worktreeId }) + }) +} + +describe('useSetupScriptPromptRevalidation', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + useAppStore.setState({ activeWorktreeId: 'worktree-1' }) + }) + + afterEach(() => { + roots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.replaceChildren() + useAppStore.setState({ activeWorktreeId: null }) + vi.clearAllMocks() + }) + + it('re-inspects on window focus while the prompt shows no effective setup', async () => { + const requestRevalidation = vi.fn() + await render({ + activeRepo: GIT_REPO, + isDismissed: false, + sidebarOpen: true, + promptState: missingSetup('repo-1'), + requestRevalidation + }) + + await dispatchWindowFocus() + + expect(requestRevalidation).toHaveBeenCalledTimes(1) + }) + + it('does not re-inspect on window focus once setup is effective', async () => { + const requestRevalidation = vi.fn() + await render({ + activeRepo: GIT_REPO, + isDismissed: false, + sidebarOpen: true, + promptState: effectiveSetup('repo-1'), + requestRevalidation + }) + + await dispatchWindowFocus() + + expect(requestRevalidation).not.toHaveBeenCalled() + }) + + it('does not listen for focus while the sidebar is closed', async () => { + const requestRevalidation = vi.fn() + await render({ + activeRepo: GIT_REPO, + isDismissed: false, + sidebarOpen: false, + promptState: missingSetup('repo-1'), + requestRevalidation + }) + + await dispatchWindowFocus() + + expect(requestRevalidation).not.toHaveBeenCalled() + }) + + it('re-inspects when a worktree activates while the prompt shows no effective setup', async () => { + const requestRevalidation = vi.fn() + // Mirror the card's real lifecycle: promptState is null on mount, so the + // activation effect does not fire until a negative result has been cached. + const rerender = await render({ + activeRepo: GIT_REPO, + isDismissed: false, + sidebarOpen: true, + promptState: null, + requestRevalidation + }) + await rerender({ + activeRepo: GIT_REPO, + isDismissed: false, + sidebarOpen: true, + promptState: missingSetup('repo-1'), + requestRevalidation + }) + expect(requestRevalidation).not.toHaveBeenCalled() + + await setActiveWorktree('worktree-2') + + expect(requestRevalidation).toHaveBeenCalledTimes(1) + }) + + it('does not re-inspect on worktree activation once setup is effective', async () => { + const requestRevalidation = vi.fn() + const rerender = await render({ + activeRepo: GIT_REPO, + isDismissed: false, + sidebarOpen: true, + promptState: null, + requestRevalidation + }) + await rerender({ + activeRepo: GIT_REPO, + isDismissed: false, + sidebarOpen: true, + promptState: effectiveSetup('repo-1'), + requestRevalidation + }) + + await setActiveWorktree('worktree-2') + + expect(requestRevalidation).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts new file mode 100644 index 000000000..edeacf09c --- /dev/null +++ b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react' +import { useAppStore } from '@/store' +import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import type { Repo } from '../../../../shared/types' + +/** + * Re-runs the setup-script prompt inspection when a shared `orca.yaml` setup hook + * can have become effective outside SetupScriptPromptCard's reactive inputs, so a + * stale "Add a setup script" prompt clears without a full sidebar reopen. + */ +export function useSetupScriptPromptRevalidation(input: { + activeRepo: Repo | null + isDismissed: boolean + sidebarOpen: boolean + promptState: SetupScriptPromptInspection | null + requestRevalidation: () => void +}): void { + const { activeRepo, isDismissed, sidebarOpen, promptState, requestRevalidation } = input + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + + // Why: only revalidate while the prompt still shows no effective setup — there is + // nothing to clear (and no RPC worth spending, notably over SSH) once it is + // configured. + const showsMissingSetup = + promptState?.status === 'ok' && + promptState.repoId === activeRepo?.id && + !promptState.hasEffectiveSetup + + // Why: orca.yaml is edited on disk or the hook runs in a terminal outside React + // state. Re-inspect on window focus so returning to Orca detects it (mirrors + // useInstalledAgentSkills' focus revalidation). + useEffect(() => { + if ( + !sidebarOpen || + !activeRepo || + !isGitRepoKind(activeRepo) || + isDismissed || + !showsMissingSetup + ) { + return + } + window.addEventListener('focus', requestRevalidation) + return () => { + window.removeEventListener('focus', requestRevalidation) + } + }, [activeRepo, isDismissed, requestRevalidation, showsMissingSetup, sidebarOpen]) + + // Why: the setup hook runs during worktree creation, so activating a worktree in + // this repo can make the setup effective after a negative result was cached. Fire + // only on an actual activation change, not on mount/remount with a seeded id — + // the initial inspection already covers the mounted worktree. + const previousWorktreeIdRef = useRef(activeWorktreeId) + useEffect(() => { + const changed = previousWorktreeIdRef.current !== activeWorktreeId + previousWorktreeIdRef.current = activeWorktreeId + if (changed && showsMissingSetup) { + requestRevalidation() + } + }, [activeWorktreeId, requestRevalidation, showsMissingSetup]) +}