diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts index 192967a14e9..f01e7909a2e 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getRenderedSetupScriptPromptState } from './setup-script-prompt-render-state' +import { + getRenderedSetupScriptPromptState, + markSetupScriptPromptSaved +} from './setup-script-prompt-render-state' import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' @@ -87,3 +90,20 @@ describe('getRenderedSetupScriptPromptState', () => { ).toBeNull() }) }) + +describe('markSetupScriptPromptSaved', () => { + it('does not apply a completed save to the same repo id on another host', () => { + const remote = prompt('repo-orca', 'runtime:windows') + + expect(markSetupScriptPromptSaved(remote, repoIdentity('repo-orca', 'local'))).toBe(remote) + }) + + it('marks the prompt for the saved host effective', () => { + expect( + markSetupScriptPromptSaved( + prompt('repo-orca', 'runtime:windows'), + repoIdentity('repo-orca', 'runtime:windows') + ) + ).toMatchObject({ hasEffectiveSetup: true }) + }) +}) diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index 0f64bb6989b..45cb2842e11 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -21,12 +21,14 @@ import { openSetupScriptSettings } from './open-setup-script-settings' import { trackSetupScriptPromptExposure } from './setup-script-prompt-exposure-telemetry' import { getRenderedSetupScriptPromptState, + markSetupScriptPromptSaved, type LastVisibleSetupScriptPrompt, type SetupScriptPromptState } from './setup-script-prompt-render-state' import { useSetupScriptPromptRevalidation } from './useSetupScriptPromptRevalidation' import { translate } from '@/i18n/i18n' import { findRepoForHost, getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' function SetupScriptPromptCard(): React.JSX.Element | null { const sidebarOpen = useAppStore((s) => s.sidebarOpen) @@ -41,7 +43,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { const dismissSetupScriptPrompt = useAppStore((s) => s.dismissSetupScriptPrompt) const [promptState, setPromptState] = useState(null) const [detectedSetupDraft, setDetectedSetupDraft] = useState('') - const [isImporting, setIsImporting] = useState(false) + const [importingRepoHostIdentity, setImportingRepoHostIdentity] = useState(null) const [inspectionRetryKey, setInspectionRetryKey] = useState(0) const trackedPromptKeysRef = useRef>(new Set()) const mountedRef = useMountedRef() @@ -50,8 +52,9 @@ function SetupScriptPromptCard(): React.JSX.Element | null { () => (activeRepoId ? findRepoForHost(repos, activeRepoId, { settings }) : null), [activeRepoId, repos, settings] ) - const isDismissed = activeRepo - ? isSetupScriptPromptDismissed(activeRepo.id, dismissedRepoIds) + const activeRepoHostIdentity = activeRepo ? getRepoHostIdentity(activeRepo) : null + const isDismissed = activeRepoHostIdentity + ? isSetupScriptPromptDismissed(activeRepoHostIdentity, dismissedRepoIds) : false const lastVisiblePromptRef = useRef(null) @@ -124,6 +127,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { !isGitRepoKind(activeRepo) || isDismissed || promptState?.repoId !== activeRepo.id || + promptState.repoHostIdentity !== activeRepoHostIdentity || promptState.status !== 'ok' || promptState.hasEffectiveSetup ) { @@ -132,10 +136,11 @@ function SetupScriptPromptCard(): React.JSX.Element | null { trackSetupScriptPromptExposure({ repoId: activeRepo.id, + repoHostIdentity: activeRepoHostIdentity, promptState, trackedPromptKeys: trackedPromptKeysRef.current }) - }, [activeRepo, isDismissed, promptState, sidebarOpen]) + }, [activeRepo, activeRepoHostIdentity, isDismissed, promptState, sidebarOpen]) const handleConfigure = useCallback(() => { if (!activeRepo) { @@ -143,6 +148,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { } if ( promptState?.repoId === activeRepo.id && + promptState.repoHostIdentity === activeRepoHostIdentity && promptState.status === 'ok' && !promptState.hasEffectiveSetup ) { @@ -156,12 +162,13 @@ function SetupScriptPromptCard(): React.JSX.Element | null { ) } openLocalCommandSettings(activeRepo.id) - }, [activeRepo, openLocalCommandSettings, promptState]) + }, [activeRepo, activeRepoHostIdentity, openLocalCommandSettings, promptState]) const handleDismiss = useCallback(() => { - if (activeRepo) { + if (activeRepo && activeRepoHostIdentity) { if ( promptState?.repoId === activeRepo.id && + promptState.repoHostIdentity === activeRepoHostIdentity && promptState.status === 'ok' && !promptState.hasEffectiveSetup ) { @@ -174,9 +181,9 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }) ) } - dismissSetupScriptPrompt(activeRepo.id) + dismissSetupScriptPrompt(activeRepoHostIdentity) } - }, [activeRepo, dismissSetupScriptPrompt, promptState]) + }, [activeRepo, activeRepoHostIdentity, dismissSetupScriptPrompt, promptState]) const saveSetupCandidate = useCallback( async (input: { @@ -189,11 +196,17 @@ function SetupScriptPromptCard(): React.JSX.Element | null { if (!activeRepo) { return } - setIsImporting(true) + const importedRepoHostIdentity = getRepoHostIdentity(activeRepo) + const importedHostId = getRepoExecutionHostId(activeRepo) + setImportingRepoHostIdentity(importedRepoHostIdentity) try { const importedRepoId = activeRepo.id const nextSettings = buildImportedHookSettings(activeRepo, candidate, hasSharedHooks) - const didUpdate = await updateRepo(activeRepo.id, { hookSettings: nextSettings }) + const didUpdate = await updateRepo( + activeRepo.id, + { hookSettings: nextSettings }, + { hostId: importedHostId } + ) if (!didUpdate) { track( 'setup_script_prompt_action', @@ -232,9 +245,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { if (actionPrefix === 'save_detected_setup') { if (mountedRef.current) { setPromptState((current) => - current?.repoId === activeRepo.id && current.status === 'ok' - ? { ...current, hasEffectiveSetup: true } - : current + markSetupScriptPromptSaved(current, importedRepoHostIdentity) ) showSavedInProjectSettingsToast({ onOpenSettings: () => openLocalCommandSettings(importedRepoId), @@ -247,11 +258,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { return } if (mountedRef.current) { - setPromptState((current) => - current?.repoId === activeRepo.id && current.status === 'ok' - ? { ...current, hasEffectiveSetup: true } - : current - ) + setPromptState((current) => markSetupScriptPromptSaved(current, importedRepoHostIdentity)) const skippedCount = candidate.unsupportedFields?.length ?? 0 showSavedInProjectSettingsToast({ onOpenSettings: () => openLocalCommandSettings(importedRepoId), @@ -285,7 +292,9 @@ function SetupScriptPromptCard(): React.JSX.Element | null { } } finally { if (mountedRef.current) { - setIsImporting(false) + setImportingRepoHostIdentity((current) => + current === importedRepoHostIdentity ? null : current + ) } } }, @@ -334,20 +343,23 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }) }, [activeRepo, detectedSetupDraft, promptState, saveSetupCandidate]) - if (!sidebarOpen || !activeRepo || !isGitRepoKind(activeRepo) || isDismissed) { + if ( + !sidebarOpen || + !activeRepo || + !activeRepoHostIdentity || + !isGitRepoKind(activeRepo) || + isDismissed + ) { lastVisiblePromptRef.current = null return null } - const activeRepoHostIdentity = getRepoHostIdentity(activeRepo) - const renderedPromptState = - activeRepo && - getRenderedSetupScriptPromptState({ - promptState, - activeRepoId: activeRepo.id, - activeRepoHostIdentity, - lastVisiblePrompt: lastVisiblePromptRef.current - }) + const renderedPromptState = getRenderedSetupScriptPromptState({ + promptState, + activeRepoId: activeRepo.id, + activeRepoHostIdentity, + lastVisiblePrompt: lastVisiblePromptRef.current + }) if ( !renderedPromptState || @@ -398,7 +410,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { candidateSource={candidateSource} candidateProvenance={candidateProvenance} detectedSetupDraft={detectedSetupDraft} - isImporting={isImporting} + isImporting={importingRepoHostIdentity === activeRepoHostIdentity} renderedStateOk={renderedPromptState.status === 'ok'} onDismiss={handleDismiss} onRetryInspection={handleRetryInspection} diff --git a/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts b/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts index 1f62941d592..a4f7028b19b 100644 --- a/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts +++ b/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts @@ -1,15 +1,17 @@ import { track } from '@/lib/telemetry' -import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' import { buildSetupScriptPromptTelemetry } from '../../../../shared/setup-script-telemetry' +import type { SetupScriptPromptState } from './setup-script-prompt-render-state' export function trackSetupScriptPromptExposure(input: { repoId: string - promptState: SetupScriptPromptInspection | null + repoHostIdentity: string + promptState: SetupScriptPromptState | null trackedPromptKeys: Set }): void { - const { promptState, repoId, trackedPromptKeys } = input + const { promptState, repoHostIdentity, repoId, trackedPromptKeys } = input if ( promptState?.repoId !== repoId || + promptState.repoHostIdentity !== repoHostIdentity || promptState.status !== 'ok' || promptState.hasEffectiveSetup ) { @@ -23,7 +25,7 @@ export function trackSetupScriptPromptExposure(input: { // Why: React may re-render the sidebar often; this event should represent // a distinct prompt exposure for this repo/source, not render churn. const promptKey = [ - repoId, + repoHostIdentity, telemetry.mode, telemetry.provider ?? 'none', telemetry.file_count_bucket, diff --git a/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts index dc693a317a5..0b9d600c4d5 100644 --- a/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts +++ b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts @@ -8,6 +8,15 @@ export type LastVisibleSetupScriptPrompt = { state: SetupScriptPromptState } +export function markSetupScriptPromptSaved( + current: SetupScriptPromptState | null, + savedRepoHostIdentity: string +): SetupScriptPromptState | null { + return current?.repoHostIdentity === savedRepoHostIdentity && current.status === 'ok' + ? { ...current, hasEffectiveSetup: true } + : current +} + export function getRenderedSetupScriptPromptState(input: { promptState: SetupScriptPromptState | null activeRepoId: string diff --git a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx index 4d7d09bbd8d..6ea400b5bd8 100644 --- a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx +++ b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx @@ -4,25 +4,34 @@ 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' +import type { SetupScriptPromptState } from './setup-script-prompt-render-state' +import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' const GIT_REPO = { id: 'repo-1', kind: 'git' } as unknown as Repo +type OkSetupScriptPromptState = Extract -function missingSetup(repoId: string): SetupScriptPromptInspection { - return { status: 'ok', repoId, hasEffectiveSetup: false, hasSharedHooks: true, candidate: null } +function missingSetup(repoId: string, hostId = 'local'): OkSetupScriptPromptState { + return { + status: 'ok', + repoId, + repoHostIdentity: getRepoHostIdentityForParts(repoId, hostId), + hasEffectiveSetup: false, + hasSharedHooks: true, + candidate: null + } } -function effectiveSetup(repoId: string): SetupScriptPromptInspection { - return { status: 'ok', repoId, hasEffectiveSetup: true, hasSharedHooks: true, candidate: null } +function effectiveSetup(repoId: string): OkSetupScriptPromptState { + return { ...missingSetup(repoId), hasEffectiveSetup: true } } type HarnessProps = { activeRepo: Repo | null isDismissed: boolean sidebarOpen: boolean - promptState: SetupScriptPromptInspection | null + promptState: SetupScriptPromptState | null requestRevalidation: () => void } @@ -117,6 +126,22 @@ describe('useSetupScriptPromptRevalidation', () => { expect(requestRevalidation).not.toHaveBeenCalled() }) + it('does not re-inspect when prompt state belongs to another host with the same repo id', async () => { + const requestRevalidation = vi.fn() + await render({ + activeRepo: GIT_REPO, + isDismissed: false, + sidebarOpen: true, + promptState: missingSetup('repo-1', 'runtime:windows'), + requestRevalidation + }) + + await dispatchWindowFocus() + await setActiveWorktree('worktree-2') + + 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 diff --git a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts index edeacf09cde..04f9dbcab90 100644 --- a/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts +++ b/src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.ts @@ -1,8 +1,9 @@ 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' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import type { SetupScriptPromptState } from './setup-script-prompt-render-state' /** * Re-runs the setup-script prompt inspection when a shared `orca.yaml` setup hook @@ -13,7 +14,7 @@ export function useSetupScriptPromptRevalidation(input: { activeRepo: Repo | null isDismissed: boolean sidebarOpen: boolean - promptState: SetupScriptPromptInspection | null + promptState: SetupScriptPromptState | null requestRevalidation: () => void }): void { const { activeRepo, isDismissed, sidebarOpen, promptState, requestRevalidation } = input @@ -25,6 +26,7 @@ export function useSetupScriptPromptRevalidation(input: { const showsMissingSetup = promptState?.status === 'ok' && promptState.repoId === activeRepo?.id && + Boolean(activeRepo && promptState.repoHostIdentity === getRepoHostIdentity(activeRepo)) && !promptState.hasEffectiveSetup // Why: orca.yaml is edited on disk or the hook runs in a terminal outside React diff --git a/src/renderer/src/lib/setup-script-prompt.test.ts b/src/renderer/src/lib/setup-script-prompt.test.ts index e592856324e..70d226bc0cf 100644 --- a/src/renderer/src/lib/setup-script-prompt.test.ts +++ b/src/renderer/src/lib/setup-script-prompt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SetupScriptImportCandidate } from '../../../shared/setup-script-imports' import type { Repo } from '../../../shared/types' +import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' import { buildImportedHookSettings, filterSetupScriptPromptDismissalsToValidRepos, @@ -177,24 +178,51 @@ describe('setup script prompt inspection', () => { }) it('ignores legacy prompt dismissals and honors the generation prompt version', () => { - expect(isSetupScriptPromptDismissed('repo-1', ['repo-1'])).toBe(false) + const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') + expect(isSetupScriptPromptDismissed(localIdentity, ['repo-1'])).toBe(false) expect( - isSetupScriptPromptDismissed('repo-1', [getSetupScriptPromptDismissalKey('repo-1')]) + isSetupScriptPromptDismissed(localIdentity, [getSetupScriptPromptDismissalKey(localIdentity)]) ).toBe(true) }) - it('keeps only current-version prompt dismissals for valid repos', () => { + it('keeps prompt dismissals only for valid repo hosts and migrates unambiguous repo ids', () => { + const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') + const remoteIdentity = getRepoHostIdentityForParts('repo-1', 'runtime:windows') expect( filterSetupScriptPromptDismissalsToValidRepos( [ 'repo-1', getSetupScriptPromptDismissalKey('repo-1'), - getSetupScriptPromptDismissalKey('repo-1'), - getSetupScriptPromptDismissalKey('repo-2') + getSetupScriptPromptDismissalKey(localIdentity), + getSetupScriptPromptDismissalKey(localIdentity), + getSetupScriptPromptDismissalKey(remoteIdentity) ], - new Set(['repo-1']) + new Set([localIdentity]) ) - ).toEqual([getSetupScriptPromptDismissalKey('repo-1')]) + ).toEqual([getSetupScriptPromptDismissalKey(localIdentity)]) + }) + + it('drops a legacy repo-id dismissal when that id exists on multiple hosts', () => { + const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') + const remoteIdentity = getRepoHostIdentityForParts('repo-1', 'runtime:windows') + + expect( + filterSetupScriptPromptDismissalsToValidRepos( + [getSetupScriptPromptDismissalKey('repo-1')], + new Set([localIdentity, remoteIdentity]) + ) + ).toEqual([]) + }) + + it('does not reuse a setup prompt dismissal on another host with the same repo id', () => { + const localIdentity = getRepoHostIdentityForParts('repo-1', 'local') + const remoteIdentity = getRepoHostIdentityForParts('repo-1', 'runtime:windows') + + expect( + isSetupScriptPromptDismissed(remoteIdentity, [ + getSetupScriptPromptDismissalKey(localIdentity) + ]) + ).toBe(false) }) it('formats setup candidate provenance for sidebar review copy', () => { diff --git a/src/renderer/src/lib/setup-script-prompt.ts b/src/renderer/src/lib/setup-script-prompt.ts index e60e3eaa7dd..d85a3a9ec30 100644 --- a/src/renderer/src/lib/setup-script-prompt.ts +++ b/src/renderer/src/lib/setup-script-prompt.ts @@ -97,25 +97,45 @@ export function ignoresSharedSetupScripts(repo: Pick): boo ) } -export function getSetupScriptPromptDismissalKey(repoId: string): string { - return `${SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX}${repoId}` +export function getSetupScriptPromptDismissalKey(repoHostIdentity: string): string { + return `${SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX}${repoHostIdentity}` } export function isSetupScriptPromptDismissed( - repoId: string, + repoHostIdentity: string, dismissedEntries: readonly string[] ): boolean { - return dismissedEntries.includes(getSetupScriptPromptDismissalKey(repoId)) + return dismissedEntries.includes(getSetupScriptPromptDismissalKey(repoHostIdentity)) } export function filterSetupScriptPromptDismissalsToValidRepos( value: unknown, - validRepoIds: Set + validRepoHostIdentities: Set ): string[] { - return sanitizeSetupScriptPromptDismissals(value).filter((entry) => { - const repoId = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length) - return validRepoIds.has(repoId) - }) + const unambiguousIdentityByRepoId = new Map() + for (const identity of validRepoHostIdentities) { + const separatorIndex = identity.indexOf('\0') + const repoId = separatorIndex >= 0 ? identity.slice(separatorIndex + 1) : identity + unambiguousIdentityByRepoId.set( + repoId, + unambiguousIdentityByRepoId.has(repoId) ? null : identity + ) + } + + const next: string[] = [] + for (const entry of sanitizeSetupScriptPromptDismissals(value)) { + const repoHostIdentity = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length) + const validIdentity = validRepoHostIdentities.has(repoHostIdentity) + ? repoHostIdentity + : unambiguousIdentityByRepoId.get(repoHostIdentity) + if (validIdentity) { + const validEntry = getSetupScriptPromptDismissalKey(validIdentity) + if (!next.includes(validEntry)) { + next.push(validEntry) + } + } + } + return next } export function sanitizeSetupScriptPromptDismissals(value: unknown): string[] { diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index f77e4db2b1e..638941df094 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -785,8 +785,8 @@ describe('fetchReposForAllHosts', () => { it('preserves remote repo filters during first-paint local catalog refresh', async () => { const store = createTestStore() - const remoteDismissalKey = getSetupScriptPromptDismissalKey('remote-repo') - const staleDismissalKey = getSetupScriptPromptDismissalKey('stale-repo') + const remoteDismissalKey = getSetupScriptPromptDismissalKey('runtime:env-1\0remote-repo') + const staleDismissalKey = getSetupScriptPromptDismissalKey('local\0stale-repo') store.setState({ activeRepoId: 'remote-repo', filterRepoIds: ['remote-repo', 'stale-repo'], diff --git a/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts b/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts index 4dc22196f7f..f805b66ac0c 100644 --- a/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts +++ b/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/types' import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt' import { createTestStore } from './store-test-helpers' +import { getRepoHostIdentityForParts } from './repo-host-identity' const localRepo: Repo = { id: 'local-repo', @@ -27,19 +28,20 @@ beforeEach(() => { describe('repo setup script prompt dismissals', () => { it('keeps only current setup prompt dismissals for fetched repos', async () => { reposList.mockResolvedValue([localRepo]) + const localIdentity = getRepoHostIdentityForParts(localRepo.id, 'local') const store = createTestStore() store.setState({ setupScriptPromptDismissedRepoIds: [ localRepo.id, - getSetupScriptPromptDismissalKey(localRepo.id), - getSetupScriptPromptDismissalKey('stale-repo') + getSetupScriptPromptDismissalKey(localIdentity), + getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts('stale-repo', 'local')) ] }) await store.getState().fetchRepos() expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([ - getSetupScriptPromptDismissalKey(localRepo.id) + getSetupScriptPromptDismissalKey(localIdentity) ]) }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 8544817d07a..82ec1e26d59 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1691,6 +1691,7 @@ export const createRepoSlice: StateCreator = (set, const reconciliation = reconcileSupersededSshRepos(result.repos, s) const prunedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const validRepoIds = new Set(prunedRepos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(prunedRepos.map(getRepoHostIdentity)) const projectCompatibility = projectCompatibilityForReconciledRepos( prunedRepos, catalog.projectHostSetupCompatibility @@ -1721,7 +1722,7 @@ export const createRepoSlice: StateCreator = (set, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, - validRepoIds + validRepoHostIdentities ) } }) @@ -1763,6 +1764,7 @@ export const createRepoSlice: StateCreator = (set, const reconciliation = reconcileSupersededSshRepos(result.repos, s) const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(finalizedRepos.map(getRepoHostIdentity)) const projectCompatibility = projectCompatibilityForReconciledRepos( finalizedRepos, catalog.projectHostSetupCompatibility @@ -1792,7 +1794,7 @@ export const createRepoSlice: StateCreator = (set, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, - validRepoIds + validRepoHostIdentities ) } }) @@ -1860,12 +1862,13 @@ export const createRepoSlice: StateCreator = (set, const validateRepoScopedUi = (): void => { set((s) => { const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) return { activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, - validRepoIds + validRepoHostIdentities ), trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(s.trustedOrcaHooks, validRepoIds) } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 4b7169cbe3b..10df98636b1 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -23,6 +23,7 @@ import { buildAgentNotificationId } from '../../../../shared/agent-notification- import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt' +import { getRepoHostIdentityForParts } from './repo-host-identity' const mocks = vi.hoisted(() => ({ sendNotesToActiveAgentSession: vi.fn(), @@ -860,7 +861,9 @@ describe('createUISlice hydratePersistedUI', () => { it('preserves persisted repo filters until repos are loaded', () => { const store = createUIStore() - const remoteDismissalKey = getSetupScriptPromptDismissalKey('remote-repo') + const remoteDismissalKey = getSetupScriptPromptDismissalKey( + getRepoHostIdentityForParts('remote-repo', 'runtime:env-1') + ) store.getState().hydratePersistedUI( makePersistedUI({ @@ -882,8 +885,12 @@ describe('createUISlice hydratePersistedUI', () => { it('validates persisted repo filters when repos are already loaded', () => { const store = createUIStore() - const localDismissalKey = getSetupScriptPromptDismissalKey('local-repo') - const staleDismissalKey = getSetupScriptPromptDismissalKey('stale-repo') + const localDismissalKey = getSetupScriptPromptDismissalKey( + getRepoHostIdentityForParts('local-repo', 'local') + ) + const staleDismissalKey = getSetupScriptPromptDismissalKey( + getRepoHostIdentityForParts('stale-repo', 'local') + ) store.setState({ repos: [ { id: 'local-repo', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 1 } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index cc81e3ae09b..9197153fde5 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -128,6 +128,7 @@ import { import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { translate } from '@/i18n/i18n' +import { getRepoHostIdentity } from './repo-host-identity' export type PendingSidebarWorktreeReveal = { worktreeId: string @@ -828,7 +829,7 @@ export type UISlice = { markOrcaHookRepoAlwaysTrusted: (repoId: string) => void clearOrcaHookTrustForRepo: (repoId: string) => void setupScriptPromptDismissedRepoIds: string[] - dismissSetupScriptPrompt: (repoId: string) => void + dismissSetupScriptPrompt: (repoHostIdentity: string) => void setupGuideSidebarDismissed: boolean setSetupGuideSidebarDismissed: (dismissed: boolean) => void setupGuideBrowserMilestoneMigrated: boolean @@ -1885,10 +1886,10 @@ export const createUISlice: StateCreator = (set, get) return { trustedOrcaHooks: next } }), setupScriptPromptDismissedRepoIds: [], - dismissSetupScriptPrompt: (repoId) => + dismissSetupScriptPrompt: (repoHostIdentity) => set((s) => { - const dismissalKey = getSetupScriptPromptDismissalKey(repoId) - if (!repoId || s.setupScriptPromptDismissedRepoIds.includes(dismissalKey)) { + const dismissalKey = getSetupScriptPromptDismissalKey(repoHostIdentity) + if (!repoHostIdentity || s.setupScriptPromptDismissedRepoIds.includes(dismissalKey)) { return s } const next = [...s.setupScriptPromptDismissedRepoIds, dismissalKey] @@ -2346,6 +2347,7 @@ export const createUISlice: StateCreator = (set, get) const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder) const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder) const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) // Why: pre-rename builds used sidekick* keys; read as fallback only so new pet* writes win after upgrade. const customPets = Array.isArray(ui.customPets) @@ -2498,11 +2500,11 @@ export const createUISlice: StateCreator = (set, get) : null, trustedOrcaHooks: hydrateTrustedOrcaHooks(ui.trustedOrcaHooks, validRepoIds), setupScriptPromptDismissedRepoIds: - validRepoIds.size === 0 + validRepoHostIdentities.size === 0 ? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds) : filterSetupScriptPromptDismissalsToValidRepos( ui.setupScriptPromptDismissedRepoIds, - validRepoIds + validRepoHostIdentities ), setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true, setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true,