mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(setup): isolate host-scoped prompt actions
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user