fix: clear loading state regardless of generation's showLoading flag

When a silent refresh supersedes an in-flight focus rescan, the losing
rescan's finally block writes are dropped by the generation guard. Only
the winning generation clears loading, so it must clear regardless of
its own showLoading setting. Also move TasksPane auto-expanded tracking
from effect to render phase to prevent layout-effect re-renders from
seeing an unclaimed slot and collapsing the card.
This commit is contained in:
Jinjing
2026-07-30 10:56:59 -07:00
parent e674c43745
commit 7b8f4881b6
4 changed files with 54 additions and 15 deletions
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useRef } from 'react'
import { Github, Gitlab } from 'lucide-react'
import type { GlobalSettings, TaskProvider } from '../../../../shared/types'
import {
@@ -109,10 +109,12 @@ export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.J
readinessByProvider,
previousAutoExpanded: previousAutoExpandedRef.current
})
// Commit-only write: a discarded render must not seed the sticky decision.
useEffect(() => {
// Claim during render, not in an effect: a layout effect elsewhere can force a
// sync re-render before passive effects flush, and that render would see an
// unclaimed slot and collapse the open card.
if (autoExpandedProvider !== null && previousAutoExpandedRef.current === null) {
previousAutoExpandedRef.current = autoExpandedProvider
}, [autoExpandedProvider])
}
const toggleProvider = (provider: TaskProvider): void => {
const isVisible = visibleProviders.includes(provider)
@@ -351,6 +351,44 @@ describe('useInstalledAgentSkill', () => {
expect(latestState?.installed).toBe(true)
})
it('clears loading when a silent refresh supersedes an in-flight focus rescan', async () => {
const firstScan = deferred<SkillDiscoveryResult>()
const focusScan = deferred<SkillDiscoveryResult>()
const discover = vi
.fn<(target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>>()
.mockReturnValueOnce(firstScan.promise)
.mockReturnValueOnce(focusScan.promise)
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills: { discover } }
})
await renderProbe()
firstScan.resolve(discoveryResult([skill({ name: 'orca-linear' })]))
await act(async () => {
await firstScan.promise
})
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(latestState?.loading).toBe(true)
// The silent refresh takes over the generation, so it owns the spinner too.
await act(async () => {
notifyInstalledAgentSkillsRefreshed()
await Promise.resolve()
})
await flushMicrotasks()
expect(latestState?.loading).toBe(false)
focusScan.resolve(discoveryResult([skill({ name: 'orca-linear' })]))
await act(async () => {
await focusScan.promise
})
expect(latestState?.loading).toBe(false)
})
it('reports unsettled again when the discovery target changes', async () => {
const hostScan = deferred<SkillDiscoveryResult>()
const wslScan = deferred<SkillDiscoveryResult>()
@@ -185,11 +185,9 @@ export function useInstalledAgentSkillNames(
}
if (!enabled) {
if (showLoading) {
writeIfCurrent(() => {
setLoading(false)
})
}
writeIfCurrent(() => {
setLoading(false)
})
return false
}
if (showLoading) {
@@ -221,11 +219,12 @@ export function useInstalledAgentSkillNames(
)
})
} finally {
if (showLoading) {
writeIfCurrent(() => {
setLoading(false)
})
}
// Why: a silent refresh can supersede an in-flight loading one, whose own
// clear is then dropped by the generation guard. Only the winning
// generation clears, so it must clear regardless of its own showLoading.
writeIfCurrent(() => {
setLoading(false)
})
}
return installedAfterRefresh
},
+1 -1
View File
@@ -7081,7 +7081,7 @@
"incompleteBannerTitle": "표시 중인 일부 제공업체에 설정이 필요합니다",
"incompleteBannerBody": "사용하지 않는 제공업체를 숨기거나 카드를 펼쳐 단계를 완료하세요.",
"providersDescription": "각 카드에서 연결, 작업 표시 여부, Linear의 경우 스킬 설정까지 진행할 수 있습니다.",
"integrationsHint": "모든 제공업체의 자격 증명은 다음에서도 관리할 수 있습니다.",
"integrationsHint": "모든 제공업체의 자격 증명은 다음에서도 관리할 수 있습니다:",
"integrationsLink": "연동",
"skillHint": ". Linear를 연결한 후 사용 예시는 설정 → Linear에서 확인할 수 있습니다.",
"incompleteBannerBodyWithLinear": "사용하지 않는 제공업체를 숨기거나 카드를 펼쳐 단계를 완료하세요. Linear에는 API 액세스, 에이전트 스킬, 작업에 표시가 필요합니다."