From 7b8f4881b6691667e141fbad6f299bda4eb4bb5a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:56:59 -0700 Subject: [PATCH] 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. --- .../src/components/settings/TasksPane.tsx | 10 +++-- .../useInstalledAgentSkills.react.test.tsx | 38 +++++++++++++++++++ .../src/hooks/useInstalledAgentSkills.ts | 19 +++++----- src/renderer/src/i18n/locales/ko.json | 2 +- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/settings/TasksPane.tsx b/src/renderer/src/components/settings/TasksPane.tsx index a9bc0d167b9..325628adbb2 100644 --- a/src/renderer/src/components/settings/TasksPane.tsx +++ b/src/renderer/src/components/settings/TasksPane.tsx @@ -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) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx index 15a9f9bf181..f6b446964ac 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx +++ b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx @@ -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() + const focusScan = deferred() + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .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() const wslScan = deferred() diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index cbd69ded4e2..482ea67386d 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -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 }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7de06dff492..7a07bba6ae1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7081,7 +7081,7 @@ "incompleteBannerTitle": "표시 중인 일부 제공업체에 설정이 필요합니다", "incompleteBannerBody": "사용하지 않는 제공업체를 숨기거나 카드를 펼쳐 단계를 완료하세요.", "providersDescription": "각 카드에서 연결, 작업 표시 여부, Linear의 경우 스킬 설정까지 진행할 수 있습니다.", - "integrationsHint": "모든 제공업체의 자격 증명은 다음에서도 관리할 수 있습니다.", + "integrationsHint": "모든 제공업체의 자격 증명은 다음에서도 관리할 수 있습니다:", "integrationsLink": "연동", "skillHint": ". Linear를 연결한 후 사용 예시는 설정 → Linear에서 확인할 수 있습니다.", "incompleteBannerBodyWithLinear": "사용하지 않는 제공업체를 숨기거나 카드를 펼쳐 단계를 완료하세요. Linear에는 API 액세스, 에이전트 스킬, 작업에 표시가 필요합니다."