fix(useInstalledSkills): keep agent skills list stable while toggling (#14472)

### What this PR does

Before this PR:

Enabling or disabling a skill in agent mode refreshed the whole
installed skills list, which unmounted the scrollable content and jumped
the view back to the top.

After this PR:

The toggled skill is updated in place, so the list stays mounted and the
scroll position is preserved.

Fixes #14461

### Why we need it and why it was done in this way

The following tradeoffs were made:

Avoiding a full list refresh on every toggle keeps the UI stable and
prevents the scroll reset.

The following alternatives were considered:

Preserving scroll position across a list refresh in the scrollbar
wrapper, but updating the toggled item directly is smaller and avoids
remounting the list.

Links to places where the discussion took place:

https://github.com/CherryHQ/cherry-studio/issues/14461

### Breaking changes

None.

### Special notes for your reviewer

The hook test covers the in-place update behavior and verifies the list
is not reloaded after a toggle.

### Checklist

This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [ ] Refactor: You have [left the code cleaner than you found
it](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [x] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
Fix the agent skills page so enabling or disabling a skill no longer resets the scroll position to the top.
```

---------

Signed-off-by: 404-Page-Found <Lucas20220605@gmail.com>
This commit is contained in:
404-Page-Found
2026-04-22 20:34:51 +10:00
committed by GitHub
parent fbf73962cb
commit 47fb14c65e
2 changed files with 85 additions and 2 deletions

View File

@@ -0,0 +1,78 @@
import type { InstalledSkill } from '@renderer/types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useInstalledSkills } from '../useSkills'
const mockList = vi.fn()
const mockToggle = vi.fn()
const mockUninstall = vi.fn()
function createSkill(overrides: Partial<InstalledSkill> = {}): InstalledSkill {
return {
id: 'skill-1',
name: 'Skill One',
description: 'First skill',
folderName: 'skill-one',
source: 'builtin',
sourceUrl: null,
namespace: null,
author: null,
tags: [],
contentHash: 'hash-1',
isEnabled: false,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
describe('useInstalledSkills', () => {
beforeEach(() => {
mockList.mockResolvedValue({
success: true,
data: [
createSkill(),
createSkill({ id: 'skill-2', name: 'Skill Two', folderName: 'skill-two', contentHash: 'hash-2' })
]
})
mockToggle.mockImplementation(async ({ skillId, isEnabled }) => ({
success: true,
data: createSkill({ id: skillId, isEnabled, updatedAt: 2 })
}))
mockUninstall.mockResolvedValue({ success: true, data: null })
;(window as any).api = {
skill: {
list: mockList,
toggle: mockToggle,
uninstall: mockUninstall
}
}
})
afterEach(() => {
delete (window as any).api
vi.clearAllMocks()
})
it('updates the toggled skill in place without reloading the list', async () => {
const { result } = renderHook(() => useInstalledSkills('agent-1'))
await waitFor(() => {
expect(result.current.skills).toHaveLength(2)
})
let toggleSuccess = false
await act(async () => {
toggleSuccess = await result.current.toggle('skill-1', true)
})
expect(toggleSuccess).toBe(true)
expect(mockToggle).toHaveBeenCalledWith({ skillId: 'skill-1', agentId: 'agent-1', isEnabled: true })
expect(mockList).toHaveBeenCalledTimes(1)
expect(result.current.skills.find((skill) => skill.id === 'skill-1')).toEqual(
createSkill({ id: 'skill-1', isEnabled: true, updatedAt: 2 })
)
})
})

View File

@@ -47,14 +47,19 @@ export function useInstalledSkills(agentId?: string) {
try {
const result = await window.api.skill.toggle({ skillId, agentId, isEnabled })
if (result.success) {
await refresh()
const updatedSkill = result.data
if (updatedSkill) {
setSkills((currentSkills) =>
currentSkills.map((skill) => (skill.id === updatedSkill.id ? updatedSkill : skill))
)
}
}
return result.success
} catch {
return false
}
},
[agentId, refresh]
[agentId]
)
const uninstall = useCallback(