Skip to content

Commit 5351d87

Browse files
Merge pull request #159 from laststance/fix/cleanup-agent-dialog-hooks
Fix cleanup dialog hook-order crash
2 parents db2bbc5 + ec67788 commit 5351d87

2 files changed

Lines changed: 140 additions & 18 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { configureStore } from '@reduxjs/toolkit'
2+
import { Provider } from 'react-redux'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { render } from 'vitest-browser-react'
5+
6+
import type { AgentId, SyncPreviewResult } from '@/shared/types'
7+
8+
const mockSyncPreview = vi.fn()
9+
const mockSyncExecute = vi.fn()
10+
11+
const SCOPED_PREVIEW: SyncPreviewResult = {
12+
totalSkills: 4,
13+
totalAgents: 1,
14+
toCreate: 2,
15+
alreadySynced: 2,
16+
conflicts: [],
17+
forAgent: 'claude-code',
18+
}
19+
20+
beforeEach(() => {
21+
mockSyncPreview.mockReset()
22+
mockSyncPreview.mockResolvedValue(SCOPED_PREVIEW)
23+
mockSyncExecute.mockReset()
24+
mockSyncExecute.mockResolvedValue({ details: [] })
25+
// Browser mode replaces Electron's preload bridge, so install the sync IPC
26+
// surface that CleanupAgentDialog reaches through the Redux thunks.
27+
vi.stubGlobal('electron', {
28+
sync: {
29+
preview: mockSyncPreview,
30+
execute: mockSyncExecute,
31+
},
32+
})
33+
})
34+
35+
afterEach(() => {
36+
vi.unstubAllGlobals()
37+
})
38+
39+
/**
40+
* Build the smallest Redux store CleanupAgentDialog needs.
41+
* @returns Store with the ui slice wired for preview target dispatches.
42+
* @example
43+
* const store = await createStore()
44+
* store.dispatch(setCleanupAgentTarget('claude-code'))
45+
*/
46+
async function createStore() {
47+
const { default: uiReducer } =
48+
await import('@/renderer/src/redux/slices/uiSlice')
49+
return configureStore({
50+
reducer: { ui: uiReducer },
51+
})
52+
}
53+
54+
/**
55+
* Opens the dialog through Redux after the component's first closed render.
56+
* This mirrors the production path where AgentItem sets cleanupAgentTarget
57+
* from a context-menu click after CleanupAgentDialog has already mounted.
58+
* @param agentId - Agent receiving the scoped cleanup preview.
59+
* @returns The rendered browser screen and backing store.
60+
* @example
61+
* const { screen } = await renderClosedThenOpen('claude-code')
62+
*/
63+
async function renderClosedThenOpen(agentId: AgentId) {
64+
const store = await createStore()
65+
const { CleanupAgentDialog } = await import('./CleanupAgentDialog')
66+
const { setCleanupAgentTarget } =
67+
await import('@/renderer/src/redux/slices/uiSlice')
68+
69+
const screen = await render(
70+
<Provider store={store}>
71+
<CleanupAgentDialog />
72+
</Provider>,
73+
)
74+
75+
// The crash video opened the dialog from an already-mounted, closed state.
76+
store.dispatch(setCleanupAgentTarget(agentId))
77+
78+
return { screen, store }
79+
}
80+
81+
describe('CleanupAgentDialog', () => {
82+
it('stays closed and skips preview when no agent target is active', async () => {
83+
const store = await createStore()
84+
const { CleanupAgentDialog } = await import('./CleanupAgentDialog')
85+
86+
const screen = await render(
87+
<Provider store={store}>
88+
<CleanupAgentDialog />
89+
</Provider>,
90+
)
91+
92+
await new Promise((resolve) => setTimeout(resolve, 0))
93+
expect(mockSyncPreview).toHaveBeenCalledTimes(0)
94+
expect(screen.getByText(/Cleanup missing skills/i).query()).toBeNull()
95+
expect(
96+
screen.getByRole('button', { name: /Cleanup \d+ skills/ }).query(),
97+
).toBeNull()
98+
})
99+
100+
it('opens from the closed state without changing the hook order', async () => {
101+
const { screen } = await renderClosedThenOpen('claude-code')
102+
103+
await expect
104+
.poll(() =>
105+
mockSyncPreview.mock.calls.some(
106+
([options]) => options?.agentId === 'claude-code',
107+
),
108+
)
109+
.toBe(true)
110+
await expect
111+
.element(screen.getByText(/Cleanup missing skills.*Claude Code/))
112+
.toBeInTheDocument()
113+
await expect.element(screen.getByText('Symlinks to create')).toBeVisible()
114+
await expect
115+
.element(screen.getByRole('button', { name: 'Cleanup 2 skills' }))
116+
.toBeVisible()
117+
})
118+
})

src/renderer/src/components/sidebar/CleanupAgentDialog.tsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -82,31 +82,17 @@ export const CleanupAgentDialog = React.memo(
8282
)
8383
}, [cleanupAgentTarget, dispatch])
8484

85-
if (!cleanupAgentTarget) return null
86-
87-
// Stale-preview guard: only trust the preview when its `forAgent`
88-
// matches the currently-targeted agent. Defends against the race
89-
// where a global preview lands while a scoped fetch is in flight.
90-
const previewMatchesTarget =
91-
syncPreview?.forAgent === cleanupAgentTarget ? syncPreview : null
92-
93-
const agentName: AgentName | undefined = AGENT_DEFINITIONS.find(
94-
(a) => a.id === cleanupAgentTarget,
95-
)?.name
96-
97-
const conflictCount = previewMatchesTarget?.conflicts.length ?? 0
98-
const missingCount = previewMatchesTarget?.toCreate ?? 0
99-
const alreadySyncedCount = previewMatchesTarget?.alreadySynced ?? 0
100-
const hasWork = missingCount > 0
101-
const isLoadingPreview = !previewMatchesTarget
102-
10385
const handleClose = useCallback((): void => {
10486
if (!isExecuting) {
10587
dispatch(clearCleanupAgentTarget())
10688
}
10789
}, [dispatch, isExecuting])
10890

10991
const handleCleanup = useCallback(async (): Promise<void> => {
92+
// When the dialog is closed this handler can still exist from the
93+
// stable hook order; only execute once a concrete agent owns the flow.
94+
if (!cleanupAgentTarget) return
95+
11096
const succeeded = await executeCleanup({
11197
replaceConflicts: [],
11298
agentId: cleanupAgentTarget,
@@ -119,6 +105,24 @@ export const CleanupAgentDialog = React.memo(
119105
}
120106
}, [cleanupAgentTarget, dispatch, executeCleanup])
121107

108+
if (!cleanupAgentTarget) return null
109+
110+
// Stale-preview guard: only trust the preview when its `forAgent`
111+
// matches the currently-targeted agent. Defends against the race
112+
// where a global preview lands while a scoped fetch is in flight.
113+
const previewMatchesTarget =
114+
syncPreview?.forAgent === cleanupAgentTarget ? syncPreview : null
115+
116+
const agentName: AgentName | undefined = AGENT_DEFINITIONS.find(
117+
(a) => a.id === cleanupAgentTarget,
118+
)?.name
119+
120+
const conflictCount = previewMatchesTarget?.conflicts.length ?? 0
121+
const missingCount = previewMatchesTarget?.toCreate ?? 0
122+
const alreadySyncedCount = previewMatchesTarget?.alreadySynced ?? 0
123+
const hasWork = missingCount > 0
124+
const isLoadingPreview = !previewMatchesTarget
125+
122126
return (
123127
<Dialog open onOpenChange={handleClose}>
124128
<DialogContent className="max-w-md">

0 commit comments

Comments
 (0)