diff --git a/apps/desktop/src/plugins/hermes-bots/pet.test.tsx b/apps/desktop/src/plugins/hermes-bots/pet.test.tsx
index 6e913cb28b..a3d08e8728 100644
--- a/apps/desktop/src/plugins/hermes-bots/pet.test.tsx
+++ b/apps/desktop/src/plugins/hermes-bots/pet.test.tsx
@@ -12,7 +12,7 @@
* failure must be evicted; a success must not be refetched.
*/
-import { render, waitFor } from '@testing-library/react'
+import { fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { hostMock, UnboundedCache, useQueryMock } = vi.hoisted(() => ({
@@ -81,6 +81,55 @@ afterEach(() => {
vi.restoreAllMocks()
})
+describe('the pet gallery', () => {
+ it('reserves paint space around boundary tiles inside the bounded scroller', async () => {
+ stubFetch(async () => ({ blob: async () => new Blob() }))
+ const PetTab = await loadPetTab()
+ const view = render()
+ await waitFor(() => expect(view.container.querySelector('img')).toBeTruthy())
+ const tile = view.getByText('Axolotl').closest('button')!
+ const scroller = tile.parentElement!.parentElement!
+
+ // The selection ring paints one pixel outside each tile. The scrollport
+ // must leave room for it even at the first/last row and outer columns.
+ const style = getComputedStyle(scroller)
+
+ for (const side of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'] as const) {
+ expect(parseFloat(style[side]) || 0).toBeGreaterThanOrEqual(1)
+ }
+
+ expect(parseFloat(style.maxHeight)).toBeGreaterThan(0)
+ view.unmount()
+ })
+
+ it('keeps selection while scrolling for more and resets the search window', async () => {
+ useQueryMock.mockReturnValue({ data: { pets: Array.from({ length: 60 }, (_, i) => ({
+ displayName: `Pet ${i}`, slug: `pet-${i}`, spritesheetUrl: SHEET
+ })) } })
+ stubFetch(async () => ({ blob: async () => new Blob() }))
+ const PetTab = await loadPetTab()
+ const onImage = vi.fn()
+ const view = render()
+ const first = view.getByText('Pet 0').closest('button')!
+ fireEvent.click(first)
+ await waitFor(() => expect(onImage).toHaveBeenCalledWith('data:image/png;base64,ok'))
+ const scroller = first.parentElement!.parentElement!
+ Object.defineProperties(scroller, {
+ clientHeight: { value: 220 }, scrollHeight: { value: 600 }, scrollTop: { value: 400 }
+ })
+ fireEvent.scroll(scroller)
+ expect(view.getByText('Pet 47')).toBeTruthy()
+ expect(view.queryByText('Pet 48')).toBeNull()
+ expect(first.className).toContain('ring-1')
+ fireEvent.change(view.getByRole('textbox'), { target: { value: 'Pet 59' } })
+ expect(view.getByText('Pet 59')).toBeTruthy()
+ fireEvent.change(view.getByRole('textbox'), { target: { value: '' } })
+ expect(view.queryByText('Pet 24')).toBeNull()
+ expect(onImage).toHaveBeenCalledTimes(1)
+ view.unmount()
+ })
+})
+
describe('the sprite-frame cache', () => {
it('never leaves a failed fetch parked in the cache', async () => {
stubFetch(async () => {
diff --git a/apps/desktop/src/plugins/hermes-bots/pet.tsx b/apps/desktop/src/plugins/hermes-bots/pet.tsx
index 700e1b98f4..b14fded278 100644
--- a/apps/desktop/src/plugins/hermes-bots/pet.tsx
+++ b/apps/desktop/src/plugins/hermes-bots/pet.tsx
@@ -230,7 +230,9 @@ export function PetTab({ image, onImage }: PetTabProps) {
className="overflow-y-auto"
onScroll={onScroll}
style={{
- maxHeight: 220
+ // Leave room for the selection ring outside boundary tiles.
+ maxHeight: 220,
+ padding: 2
}}
>