fix(setup): isolate host-scoped prompt actions

This commit is contained in:
Brennan Benson
2026-07-29 18:25:49 -07:00
parent a89f4de86e
commit 9608741af2
13 changed files with 208 additions and 76 deletions
@@ -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 })
})
})
@@ -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<SetupScriptPromptState | null>(null)
const [detectedSetupDraft, setDetectedSetupDraft] = useState('')
const [isImporting, setIsImporting] = useState(false)
const [importingRepoHostIdentity, setImportingRepoHostIdentity] = useState<string | null>(null)
const [inspectionRetryKey, setInspectionRetryKey] = useState(0)
const trackedPromptKeysRef = useRef<Set<string>>(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<LastVisibleSetupScriptPrompt | null>(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}
@@ -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<string>
}): 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,
@@ -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
@@ -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<SetupScriptPromptState, { status: 'ok' }>
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
@@ -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
@@ -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', () => {
+29 -9
View File
@@ -97,25 +97,45 @@ export function ignoresSharedSetupScripts(repo: Pick<Repo, 'hookSettings'>): 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<string>
validRepoHostIdentities: Set<string>
): string[] {
return sanitizeSetupScriptPromptDismissals(value).filter((entry) => {
const repoId = entry.slice(SETUP_SCRIPT_PROMPT_DISMISSAL_PREFIX.length)
return validRepoIds.has(repoId)
})
const unambiguousIdentityByRepoId = new Map<string, string | null>()
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[] {
@@ -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'],
@@ -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)
])
})
})
+6 -3
View File
@@ -1691,6 +1691,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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<AppState, [], [], RepoSlice> = (set,
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
s.setupScriptPromptDismissedRepoIds,
validRepoIds
validRepoHostIdentities
)
}
})
@@ -1763,6 +1764,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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<AppState, [], [], RepoSlice> = (set,
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
s.setupScriptPromptDismissedRepoIds,
validRepoIds
validRepoHostIdentities
)
}
})
@@ -1860,12 +1862,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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)
}
+10 -3
View File
@@ -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 }
+8 -6
View File
@@ -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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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,