diff --git a/apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx b/apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx index 590dd82a7dd..6342de21346 100644 --- a/apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx +++ b/apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx @@ -3,7 +3,7 @@ * * Inputs: installed Ghost snapshots and user actions. `embedded` mounts the same * catalog inside Settings; `onSelectCatalogTab` keeps Plugins / Skills in-panel. - * Outputs: the Plugin list/detail UI, focus-stable installed queue, and Plugin action flows. + * Outputs: the Plugin list/detail UI, recommendation-aware return navigation, and Plugin action flows. * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -117,6 +117,7 @@ import { PluginManagementLayout, PluginManagementPage, } from './PluginManagementLayout'; +import { usePluginListScrollRestoration } from './lib/usePluginListScrollRestoration'; import { GhostPagePanelHost } from './GhostPagePanelHost'; import { GhostPluginDetailView } from './GhostPluginDetailView'; import { @@ -489,6 +490,15 @@ export function GhostPluginPage({ }, [ignoredRoundKey]); const [originFilter, setOriginFilter] = useState('all'); const [marketDetail, setMarketDetail] = useState(null); + // 列表与详情是互斥分支;列表节点会卸载,返回时需显式恢复进入前的滚动位置。 + const pluginCatalogVisible = marketDetail === null && selectedId === null; + const { + listRef: pluginCatalogListRef, + onListScroll: onPluginCatalogScroll, + capture: capturePluginCatalogScroll, + requestRestore: requestPluginCatalogScrollRestore, + clearPendingRestore: clearPluginCatalogScrollRestore, + } = usePluginListScrollRestoration(pluginCatalogVisible); const [marketBusyId, setMarketBusyId] = useState(null); // 市场操作的同步互斥锁。React state 在提交前有窗口期,快速连点会让多个回调 // 都读到 null;ref 先到先得,state 只驱动按钮禁用等 UI 展示。每次占锁都返回 @@ -550,13 +560,14 @@ export function GhostPluginPage({ } }, []); useEffect(() => { + clearPluginCatalogScrollRestore(); setMarketSnapshot(null); setMarketDetail(null); marketBusyLockRef.current = null; setMarketBusyId(null); marketDetailRequestRef.current += 1; void refreshMarket(); - }, [refreshMarket, mode, dataOwnerId]); + }, [clearPluginCatalogScrollRestore, refreshMarket, mode, dataOwnerId]); const refreshMarketOnForeground = useCallback(() => refreshMarket(true), [refreshMarket]); usePluginMarketForegroundRefresh(refreshMarketOnForeground, lastMarketRefreshAtRef); useEffect(() => { @@ -1303,6 +1314,7 @@ export function GhostPluginPage({ // 与 handleMarketUpdate 共用同一互斥锁:更新进行中不叠加其它市场操作。 const marketBusyLease = acquireMarketBusy(pluginId); if (!marketBusyLease) return; + capturePluginCatalogScroll(); const requestId = ++marketDetailRequestRef.current; try { const detail = await window.electronAPI.pluginMarket.detail(pluginId); @@ -1323,8 +1335,14 @@ export function GhostPluginPage({ releaseMarketBusy(marketBusyLease); } }, - [acquireMarketBusy, isMarketBusyLeaseActive, releaseMarketBusy, t], + [acquireMarketBusy, capturePluginCatalogScroll, isMarketBusyLeaseActive, releaseMarketBusy, t], ); + const handleMarketBack = useCallback(() => { + cancelPendingPluginSuggestion(recommendationNonce ?? undefined); + requestPluginCatalogScrollRestore(); + marketDetailRequestRef.current += 1; + setMarketDetail(null); + }, [recommendationNonce, requestPluginCatalogScrollRestore]); const refreshVisibleMarketDetail = useCallback(async (pluginId: string) => { // A background icon renewal may observe navigation, but must never invalidate a @@ -1510,11 +1528,7 @@ export function GhostPluginPage({ { - cancelPendingPluginSuggestion(recommendationNonce ?? undefined); - marketDetailRequestRef.current += 1; - setMarketDetail(null); - }} + onBack={handleMarketBack} onInstall={ canOfferMarketInstall(mode, marketDetail.ghostId) ? () => void handleInstallFromMarket() @@ -1596,10 +1610,12 @@ export function GhostPluginPage({ >
{recommendationNotice} diff --git a/apps/desktop/src/renderer/features/plugin/PluginDetailTopBar.tsx b/apps/desktop/src/renderer/features/plugin/PluginDetailTopBar.tsx index 72596593850..3e7f5abd168 100644 --- a/apps/desktop/src/renderer/features/plugin/PluginDetailTopBar.tsx +++ b/apps/desktop/src/renderer/features/plugin/PluginDetailTopBar.tsx @@ -6,11 +6,12 @@ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { useCallback, useState, type UIEvent } from 'react'; +import { useCallback, useEffect, useState, type UIEvent } from 'react'; import { ArrowLeft } from 'lucide-react'; import { WINDOW_DRAG_STYLE, WINDOW_NO_DRAG_STYLE } from '@/components/layout/windowDrag'; import { useMacFullscreen } from '@/hooks/useMacFullscreen'; +import { isEditableKeyboardTarget } from '@/lib/editableKeyboardTarget'; import { cn } from '@/lib/utils'; interface PluginDetailTopBarProps { @@ -67,6 +68,29 @@ export function usePluginDetailScrolled(): { export function PluginDetailTopBar({ label, onBack, scrolled }: PluginDetailTopBarProps) { const { isMac } = useMacFullscreen(); + // Both installed and market detail surfaces share this escape hatch. Let + // nested dialogs and editable controls consume Escape first. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.key !== 'Escape' || + event.defaultPrevented || + event.isComposing || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + isEditableKeyboardTarget(event.target) + ) { + return; + } + event.preventDefault(); + onBack(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onBack]); + return (
({ + auth: { user: { membershipKind: 'personal' }, mode: 'local', dataOwnerId: 'navigation-owner' }, + translation: { + t: (key: string) => key, + i18n: { language: 'en', resolvedLanguage: 'en' }, + }, +})); + +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => auth })); +vi.mock('react-i18next', async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => translation, +})); +vi.mock('@/components/ui/confirm-dialog-provider', () => ({ + useConfirmDialog: () => ({ confirm: vi.fn() }), +})); + +const detail: PluginMarketDetail = { + pluginId: 'navigation-market-plugin', + ghostId: 'navigation-plugin', + name: 'Navigation Plugin', + description: 'Navigation regression fixture', + author: 'Cindy', + scope: 'public', + organizationId: null, + defaultInstall: false, + releaseId: 'navigation-release', + version: '1.0.0', + publishedAt: '2026-09-07T00:00:00.000Z', + icon: null, + installState: 'not-installed', + enabled: null, + sourceType: 'server', + sourceMarketName: null, + manifest: { + schemaVersion: 2, + id: 'navigation-plugin', + name: 'Navigation Plugin', + version: '1.0.0', + kind: 'chip', + entry: 'main.js', + }, +}; + +const suggestion: PluginSuggestionRequest = { + ownerId: 'navigation-owner', + targetKey: 'local', + workingDir: null, + model: 'test', + effort: 'medium', + permissionMode: 'default', + files: [], + suggestion: { + id: 'plugin:navigation', + category: 'email', + label: 'Navigation suggestion', + prompt: 'Do not continue after returning from plugin details', + pluginId: detail.ghostId, + }, +}; + +const loadDetail = vi.fn<() => Promise>(); + +beforeEach(() => { + translation.i18n.language = 'en'; + translation.i18n.resolvedLanguage = 'en'; + cancelPendingPluginSuggestion(); + __resetInstalledGhostsStoreForTest(); + loadDetail.mockReset().mockResolvedValue(detail); + vi.stubGlobal('electronAPI', { + platform: 'win32', + sidebarSettings: { + loadSnapshot: () => ({ hiddenMainViewGhostIds: [], dataOwnerId: null, ownerGeneration: 0 }), + onHiddenMainViewGhostIdsChanged: () => () => {}, + }, + ghosts: { + listSync: () => ({ ghosts: [] }), + recentUsageSync: () => ({ ids: [] }), + onChanged: () => () => {}, + onRecentUsageChanged: () => () => {}, + }, + pluginMarket: { + snapshot: async () => ({ + items: [detail], + unavailableReason: null, + customSourceNames: [], + unavailableCustomSourceNames: [], + }), + detail: loadDetail, + }, + setApplicationMenuLocale: async () => {}, + }); +}); + +afterEach(() => { + cleanup(); + cancelPendingPluginSuggestion(); + __resetInstalledGhostsStoreForTest(); + vi.unstubAllGlobals(); +}); + +function page(entry: string) { + return ( + + + + ); +} + +async function openFromCatalog() { + const plugin = await screen.findByText(detail.name); + const catalog = screen.getByRole('main'); + fireEvent.scroll(catalog, { target: { scrollTop: 640 } }); + fireEvent.click(plugin); + await screen.findByRole('button', { name: 'settings.ghosts.detail.backToList' }); + expect(catalog.isConnected).toBe(false); +} + +describe('plugin page return navigation', () => { + it.each(['button', 'Escape'] as const)( + '%s cancels the recommendation and restores the catalog scroll position', + async (method) => { + const nonce = startPendingPluginSuggestion(suggestion); + render(page(`/plugins?recommendation=${nonce}`)); + await openFromCatalog(); + expect(screen.getByRole('status')).toBeTruthy(); + expect(getPendingPluginSuggestion()?.nonce).toBe(nonce); + + if (method === 'button') { + fireEvent.click(screen.getByRole('button', { name: 'settings.ghosts.detail.backToList' })); + } else { + fireEvent.keyDown(window, { key: 'Escape' }); + } + + expect(screen.getByRole('main').scrollTop).toBe(640); + expect( + screen.queryByRole('button', { name: 'settings.ghosts.detail.backToList' }), + ).toBeNull(); + expect(screen.queryByRole('status')).toBeNull(); + expect(getPendingPluginSuggestion()).toBeNull(); + expect(readyPendingPluginSuggestion(nonce, suggestion.ownerId, detail.ghostId)).toBeNull(); + }, + ); + + it('restores ordinary catalog navigation without a pending recommendation', async () => { + render(page('/plugins')); + await openFromCatalog(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(screen.getByRole('main').scrollTop).toBe(640); + expect(getPendingPluginSuggestion()).toBeNull(); + }); + + it('does not reopen details when a locale refresh completes after returning', async () => { + const nonce = startPendingPluginSuggestion(suggestion); + const mounted = render(page(`/plugins?recommendation=${nonce}`)); + await openFromCatalog(); + let finishRefresh!: (value: PluginMarketDetail) => void; + loadDetail.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRefresh = resolve; + }), + ); + translation.i18n.language = 'ja'; + translation.i18n.resolvedLanguage = 'ja'; + mounted.rerender(page(`/plugins?recommendation=${nonce}`)); + await waitFor(() => expect(loadDetail).toHaveBeenCalledTimes(2)); + + fireEvent.keyDown(window, { key: 'Escape' }); + await act(async () => { + finishRefresh({ ...detail, name: 'Refreshed Plugin' }); + }); + + expect(screen.getByRole('main').scrollTop).toBe(640); + expect(screen.queryByRole('button', { name: 'settings.ghosts.detail.backToList' })).toBeNull(); + expect(getPendingPluginSuggestion()).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/features/plugin/__tests__/PluginDetailTopBar.test.tsx b/apps/desktop/src/renderer/features/plugin/__tests__/PluginDetailTopBar.test.tsx index b77c9576132..42d22cd60af 100644 --- a/apps/desktop/src/renderer/features/plugin/__tests__/PluginDetailTopBar.test.tsx +++ b/apps/desktop/src/renderer/features/plugin/__tests__/PluginDetailTopBar.test.tsx @@ -42,6 +42,64 @@ function Harness({ onBack = () => {} }: { onBack?: () => void }) { } describe('PluginDetailTopBar', () => { + it('returns to the owning list when Escape is pressed', () => { + stubPlatform('darwin'); + const onBack = vi.fn(); + + render(); + + const event = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }); + window.dispatchEvent(event); + + expect(onBack).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); + + it('leaves Escape to editable controls, modifiers, and consumed events', () => { + stubPlatform('darwin'); + const onBack = vi.fn(); + render(); + + fireEvent.keyDown(window, { key: 'Escape', metaKey: true }); + + const input = document.createElement('input'); + document.body.appendChild(input); + fireEvent.keyDown(input, { key: 'Escape' }); + input.remove(); + + const consumed = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }); + consumed.preventDefault(); + window.dispatchEvent(consumed); + + expect(onBack).not.toHaveBeenCalled(); + }); + + it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey', 'isComposing'])( + 'does not navigate back when Escape carries %s', + (flag) => { + const onBack = vi.fn(); + render(); + + fireEvent.keyDown(window, { key: 'Escape', [flag]: true }); + + expect(onBack).not.toHaveBeenCalled(); + }, + ); + + it('lets a nested surface stop Escape before it reaches the detail listener', () => { + const onBack = vi.fn(); + render( +
event.stopPropagation()}> + + +
, + ); + + fireEvent.keyDown(screen.getByRole('button', { name: 'Nested surface' }), { key: 'Escape' }); + + expect(onBack).not.toHaveBeenCalled(); + }); + it('carries the window drag region on macOS and keeps the back button clickable', () => { stubPlatform('darwin'); const onBack = vi.fn(); diff --git a/apps/desktop/src/renderer/features/plugin/__tests__/usePluginListScrollRestoration.test.tsx b/apps/desktop/src/renderer/features/plugin/__tests__/usePluginListScrollRestoration.test.tsx new file mode 100644 index 00000000000..85462408f8b --- /dev/null +++ b/apps/desktop/src/renderer/features/plugin/__tests__/usePluginListScrollRestoration.test.tsx @@ -0,0 +1,111 @@ +/** + * Regression coverage for the Plugin catalog's conditional list/detail mount. + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + * @vitest-environment jsdom + */ + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { + usePluginListScrollRestoration, + type PluginListScrollRestoration, +} from '../lib/usePluginListScrollRestoration'; + +let restoration: PluginListScrollRestoration; + +function Harness({ + listVisible, + items = ['plugin-a', 'plugin-b'], +}: { + listVisible: boolean; + items?: string[]; +}) { + restoration = usePluginListScrollRestoration(listVisible); + return listVisible ? ( +
+ {items.map((item) => ( +
+ ))} +
+ ) : ( +
+ ); +} + +function setScrollTop(element: HTMLElement, value: number) { + Object.defineProperty(element, 'scrollTop', { + configurable: true, + writable: true, + value, + }); +} + +describe('usePluginListScrollRestoration', () => { + it('restores the captured catalog position when the list is mounted again', () => { + const view = render(); + const list = screen.getByTestId('plugin-list'); + setScrollTop(list, 640); + + act(() => { + restoration.capture(); + restoration.requestRestore(); + }); + view.rerender(); + view.rerender(); + + expect(screen.getByTestId('plugin-list').scrollTop).toBe(640); + }); + + it('keeps the captured pixel position when catalog items change during detail', () => { + const view = render(); + const list = screen.getByTestId('plugin-list'); + setScrollTop(list, 640); + fireEvent.scroll(list); + + // Enter detail: the catalog list is unmounted while its data can still refresh. + act(() => { + restoration.capture(); + restoration.requestRestore(); + }); + view.rerender(); + // Simulate a market refresh that inserts an item before the previous viewport. + view.rerender( + , + ); + view.rerender( + , + ); + + expect(screen.getByTestId('plugin-plugin-new')).toBeTruthy(); + expect(screen.getByTestId('plugin-list').scrollTop).toBe(640); + }); + + it('does not restore a position unless the return path requests it', () => { + const view = render(); + const list = screen.getByTestId('plugin-list'); + setScrollTop(list, 320); + fireEvent.scroll(list); + + view.rerender(); + view.rerender(); + + expect(screen.getByTestId('plugin-list').scrollTop).toBe(0); + }); + + it('can cancel a pending restore when the owning catalog changes', () => { + const view = render(); + const list = screen.getByTestId('plugin-list'); + setScrollTop(list, 280); + fireEvent.scroll(list); + + act(() => { + restoration.requestRestore(); + restoration.clearPendingRestore(); + }); + view.rerender(); + view.rerender(); + + expect(screen.getByTestId('plugin-list').scrollTop).toBe(0); + }); +}); diff --git a/apps/desktop/src/renderer/features/plugin/lib/usePluginListScrollRestoration.ts b/apps/desktop/src/renderer/features/plugin/lib/usePluginListScrollRestoration.ts new file mode 100644 index 00000000000..248abbe7231 --- /dev/null +++ b/apps/desktop/src/renderer/features/plugin/lib/usePluginListScrollRestoration.ts @@ -0,0 +1,57 @@ +import { useCallback, useLayoutEffect, useRef, type RefObject, type UIEvent } from 'react'; + +export interface PluginListScrollRestoration { + listRef: RefObject; + onListScroll: (event: UIEvent) => void; + capture: () => void; + requestRestore: () => void; + clearPendingRestore: () => void; +} + +/** + * Keeps a catalog's scroll offset across a conditional list/detail mount. + * The caller decides when a return should restore the saved position. + */ +export function usePluginListScrollRestoration(listVisible: boolean): PluginListScrollRestoration { + const listRef = useRef(null); + const scrollTopRef = useRef(0); + const pendingRestoreRef = useRef(false); + const wasListVisibleRef = useRef(listVisible); + + const onListScroll = useCallback((event: UIEvent) => { + scrollTopRef.current = event.currentTarget.scrollTop; + }, []); + + const capture = useCallback(() => { + const list = listRef.current; + if (list) scrollTopRef.current = list.scrollTop; + }, []); + + const requestRestore = useCallback(() => { + pendingRestoreRef.current = true; + }, []); + + const clearPendingRestore = useCallback(() => { + pendingRestoreRef.current = false; + }, []); + + useLayoutEffect(() => { + const wasListVisible = wasListVisibleRef.current; + wasListVisibleRef.current = listVisible; + if (!listVisible || wasListVisible || !pendingRestoreRef.current) return; + + const list = listRef.current; + if (!list) return; + // Restore before paint so returning from detail never flashes the top. + list.scrollTop = scrollTopRef.current; + pendingRestoreRef.current = false; + }, [listVisible]); + + return { + listRef, + onListScroll, + capture, + requestRestore, + clearPendingRestore, + }; +} diff --git a/packages/maker-pi-manager/src/__tests__/edge-cases.test.ts b/packages/maker-pi-manager/src/__tests__/edge-cases.test.ts index 68d2a9595a6..a07cb68bcac 100644 --- a/packages/maker-pi-manager/src/__tests__/edge-cases.test.ts +++ b/packages/maker-pi-manager/src/__tests__/edge-cases.test.ts @@ -651,10 +651,19 @@ describe('timeout boundaries', () => { // (Windows CI) 上可能已消耗 >1ms, 不刷新会让刚创建的 session 被判过期 // (idleTimeoutMs=1 的时序竞态, Windows shard 偶发 SIGTERM)。 const entry = registry.get('just-made')!; - entry.lastActivityMono = process.hrtime.bigint(); - - // recycleIdle is fully synchronous for non-expired entries (loop/continue, no async) - (registry as any).recycleIdle(); + const now = process.hrtime.bigint(); + entry.lastActivityMono = now; + // Freeze the monotonic clock around the synchronous sweep. A 1ms threshold is + // intentionally below Windows filesystem/scheduler jitter, so measuring the + // real elapsed time here makes this boundary test flaky without testing a + // different runtime behavior. + const clock = vi.spyOn(process.hrtime, 'bigint').mockReturnValue(now); + try { + // recycleIdle is fully synchronous for non-expired entries (loop/continue, no async) + (registry as any).recycleIdle(); + } finally { + clock.mockRestore(); + } // Should NOT have been killed — activity is too recent expect(child.kill).not.toHaveBeenCalled();