Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Electron desktop app (macOS) for visualizing Skills symlink status across AI age
**version bump → notarized build → ZIP rename → GitHub release → website URL update → artifacts upload**

**Forbidden:**
- `/ship` MUST NOT bump `package.json` version. `/ship` is for code commits/PRs only. Version bumps are owned exclusively by `/electron-release`.
- `/ship` MUST NOT bump `package.json` version. Version bumps are owned exclusively by `/electron-release`.
- Manual `gh release create` outside `/electron-release` (skips notarization check, ZIP rename, website update — auto-update breaks)
- Manual edit of `package.json` `"version"` field

Expand Down
30 changes: 30 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ Rules:
- Placeholder text must stay lower contrast than real values.
- Search should feel command-palette-adjacent: compact, keyboard-first,
immediately scannable.
- Reset native form chrome to the token palette. macOS Chromium renders the
`type="search"` clear control (`::-webkit-search-cancel-button`) in the system
accent (blue), which breaks the muted OKLCH surface. Prefer a palette-matched
affordance: recolor the pseudo-element via `appearance: none` + a masked SVG in
`--muted-foreground` (preserves one-click clear), or supply a custom clear
button. Top-tier tools (Linear, Raycast, Notion) never expose raw browser
chrome. The current native blue × is an accepted low-priority item — see Polish
Backlog Guidance.

### Tabs and Segmented Controls

Expand Down Expand Up @@ -286,6 +294,21 @@ Rules:
shifts the row's content (zero-layout-shift); align overlays to the title
row, not the card's raw top edge.

### Empty States

- Empty-state prominence scales with severity. Match the treatment to whether
the empty is expected or a failure:
- Expected / transient empties (a search with no matches, a freshly filtered
list) stay to one quiet muted line. A zero-result search is a normal outcome
— typos, narrow queries — not an error, so it must not look like one.
(Spotlight, Linear, Raycast keep search misses understated.)
- Rare / terminal failures (network error, leaderboard unavailable) earn the
fuller icon + heading + description treatment, because they signal that
something actually broke and may need user action.
- Never dress an expected empty as a failure: no `h-12` icon or `text-lg`
heading for a search miss. This is the operational reading of principle 5's
"no noisy empty states."

### Cards and Widgets

- Use cards for repeated standalone items, dashboard widgets, dialogs, and
Expand Down Expand Up @@ -424,6 +447,13 @@ Likely app-safe improvements:
- Add 120-180ms color/opacity transitions to selected rows and tabs.
- Add 250-350ms width transitions to progress indicators only when they track
real progress.
- Recolor the native search clear control to the palette. Both the marketplace
and skills-tab search inputs use `type="search"`, so macOS Chromium paints the
`::-webkit-search-cancel-button` in system-accent blue. A single global rule
(`appearance: none` + masked SVG in `--muted-foreground` on the pseudo-element)
recolors both at once while preserving one-click clear; Electron's pinned
Chromium keeps the mask approach low-risk. Low priority — the blue × is the
only off-palette element in an otherwise clean search surface.

## Agent Prompt Guide

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { configureStore } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'

import { SEARCH_DEBOUNCE_MS } from '@/shared/constants'
import { repositoryId } from '@/shared/types'
import type { SkillSearchResult } from '@/shared/types'

// The unit tests cover the debounce primitive and the reducer's latest-wins
// guard in isolation. This file is the only place the *composed* incremental
// search runs end to end: a real keystroke → handleChange → debounced run →
// runSearch's commit+dispatch → the IPC `search` call → results in the store.
// It guards the wiring the lint-forced callback pivot moved into the component,
// which nothing else exercises.

const mockSearch = vi.fn()

beforeEach(() => {
// Fresh mock state per test, then re-point `window.electron.skillsCli.search`
// at it. Stub `electron` (not `window`) so the browser lane keeps its real
// window/DOM — `window.electron` resolves to this on `globalThis`.
vi.resetAllMocks()
vi.stubGlobal('electron', {
skillsCli: {
search: mockSearch,
install: vi.fn(),
cancel: vi.fn(),
onProgress: vi.fn(() => () => {}),
},
// MarketplaceSearch never dispatches loadLeaderboard, but stubbing keeps the
// test resilient if a future render path pulls the thunk in transitively.
marketplace: {
leaderboard: vi.fn(async () => []),
},
})
})

afterEach(() => {
vi.unstubAllGlobals()
})

const sampleResult: SkillSearchResult = {
rank: 1,
name: 'task',
repo: repositoryId('vercel-labs/skill-task'),
url: 'https://skills.sh/vercel-labs/skill-task',
}

/**
* Render MarketplaceSearch over a marketplace-only store. Dynamic imports run
* after `beforeEach` installs the `electron` stub, so the IPC bridge is in
* place before the component module evaluates.
*/
async function renderSearch() {
const { default: marketplaceReducer } =
await import('@/renderer/src/redux/slices/marketplaceSlice')
const store = configureStore({
reducer: { marketplace: marketplaceReducer },
})
const { MarketplaceSearch } = await import('./MarketplaceSearch')
const screen = await render(
<Provider store={store}>
<MarketplaceSearch />
</Provider>,
)
const input = screen.getByRole('searchbox', {
name: 'Search marketplace skills',
})
return { screen, store, input }
}

describe('MarketplaceSearch — incremental search', () => {
it('fires a single remote search for the final query after a burst of typing', async () => {
// Arrange
mockSearch.mockResolvedValue([sampleResult])
const { store, input } = await renderSearch()

// Act — a fast burst: each keystroke restarts the debounce window.
await input.fill('r')
await input.fill('re')
await input.fill('react')

// Assert — exactly one remote call lands, for the final query, not one per
// keystroke (that would be three calls).
await expect.poll(() => mockSearch.mock.calls.length).toBe(1)
expect(mockSearch).toHaveBeenCalledWith('react')

// Assert — the settled query is committed and its results fill the panel.
expect(store.getState().marketplace.searchQuery).toBe('react')
await expect
.poll(() => store.getState().marketplace.searchResults)
.toEqual([sampleResult])
})

it('wipes the results and returns to the leaderboard when the box is cleared', async () => {
// Arrange — run one search to completion so there is state to clear.
mockSearch.mockResolvedValue([sampleResult])
const { store, input } = await renderSearch()
await input.fill('react')
await expect
.poll(() => store.getState().marketplace.searchResults)
.toEqual([sampleResult])

// Act — empty the box.
await input.fill('')

// Assert — results cleared, query reset, panel back to its idle state.
await expect.poll(() => store.getState().marketplace.status).toBe('idle')
expect(store.getState().marketplace.searchResults).toEqual([])
expect(store.getState().marketplace.searchQuery).toBe('')
})

it('cancels the pending remote search when the box is cleared before it fires', async () => {
// Arrange — search never gets to run; the clear should pre-empt it.
const { input } = await renderSearch()

// Act — type, then clear within the same quiet window.
await input.fill('react')
await input.fill('')

// Assert — well past the debounce window, no remote call ever happened.
await new Promise((resolve) => setTimeout(resolve, SEARCH_DEBOUNCE_MS * 2))
expect(mockSearch).not.toHaveBeenCalled()
})
})
92 changes: 46 additions & 46 deletions src/renderer/src/components/marketplace/MarketplaceSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,81 @@
import { Search, Loader2 } from 'lucide-react'
import React, { useCallback, useState } from 'react'

import { Button } from '@/renderer/src/components/ui/button'
import { Input } from '@/renderer/src/components/ui/input'
import { useDebouncedCallback } from '@/renderer/src/hooks/useDebouncedCallback'
import { useAppDispatch, useAppSelector } from '@/renderer/src/redux/hooks'
import {
searchSkills,
setMarketplaceSearchQuery,
clearSearchResults,
} from '@/renderer/src/redux/slices/marketplaceSlice'
import { SEARCH_DEBOUNCE_MS } from '@/shared/constants'

/**
* Search box for finding skills in the marketplace
* Incremental search box for the marketplace. Searches as the user types: each
* keystroke updates a local value instantly and fires a debounced remote
* `skills find` call, and committing the query flips the panel from leaderboard
* to results. Deliberately button-less — incremental search means there is
* nothing to click, matching the skills-tab `SearchBox` and DESIGN.md "Polish
* by subtraction first".
*/
export const MarketplaceSearch = React.memo(
function MarketplaceSearch(): React.ReactElement {
const dispatch = useAppDispatch()
const { searchQuery, status } = useAppSelector((state) => state.marketplace)
// Local value drives the input for instant typing feedback; the debounced
// callback drives the actual (expensive, IPC-bound) remote search.
const [localQuery, setLocalQuery] = useState(searchQuery)
const isSearching = status === 'searching'

const handleSearch = useCallback((): void => {
if (localQuery.trim()) {
dispatch(setMarketplaceSearchQuery(localQuery.trim()))
dispatch(searchSkills(localQuery.trim()))
}
}, [dispatch, localQuery])
// Fire the remote search for a settled query. Committing the query to Redux
// (setMarketplaceSearchQuery) and dispatching the search together keeps the
// view switch (hasSearched) and the 'searching' status atomic, so the
// results pane never flashes "no results" while the user is mid-type.
const runSearch = useCallback(
(query: string): void => {
const trimmedQuery = query.trim()
if (trimmedQuery === '') return
dispatch(setMarketplaceSearchQuery(trimmedQuery))
dispatch(searchSkills(trimmedQuery))
},
[dispatch],
)
const debouncedSearch = useDebouncedCallback(runSearch, SEARCH_DEBOUNCE_MS)

/** Clear search input and return to leaderboard */
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value
setLocalQuery(value)
// When input is cleared (native X button or manual), reset search state
if (value === '') {
if (value.trim() === '') {
// Clearing the box snaps back to the leaderboard immediately and
// cancels the search that was about to fire for the prior keystrokes.
debouncedSearch.cancel()
dispatch(clearSearchResults())
} else {
debouncedSearch.run(value)
}
},
[dispatch],
)

const handleKeyDown = useCallback(
(e: React.KeyboardEvent): void => {
if (e.key === 'Enter') {
handleSearch()
}
},
[handleSearch],
[dispatch, debouncedSearch],
)

return (
<div className="flex gap-2">
<div className="relative flex-1">
<div className="relative">
{/* Left icon morphs to a spinner while a search is in flight — conveys
loading without a separate control or layout shift. */}
{isSearching ? (
<Loader2 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search skills (e.g., react, vercel, nextjs)..."
value={localQuery}
onChange={handleChange}
onKeyDown={handleKeyDown}
className="pl-10 bg-background h-8"
disabled={isSearching}
/>
</div>
<Button
onClick={handleSearch}
disabled={isSearching || !localQuery.trim()}
>
{isSearching ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Searching...
</>
) : (
'Search'
)}
</Button>
)}
<Input
type="search"
placeholder="Search skills (e.g., react, vercel, nextjs)..."
value={localQuery}
onChange={handleChange}
className="pl-10 bg-background h-8"
aria-label="Search marketplace skills"
/>
</div>
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ export const SkillsMarketplace = React.memo(
>
{hasSearched && (
<>
{isSearching && (
{/* First-search spinner only. While re-searching with results
already on screen, keep them visible (the search box icon
spins instead) so incremental typing never blanks the list —
same "keep stale during refresh" intent as the leaderboard. */}
{isSearching && searchResults.length === 0 && (
<div className="flex items-center justify-center py-16">
<div className="text-muted-foreground">Searching...</div>
</div>
Expand Down
48 changes: 48 additions & 0 deletions src/renderer/src/hooks/useDebouncedCallback.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { renderHook } from 'vitest-browser-react'

import { useDebouncedCallback } from './useDebouncedCallback'

// Real timers + `vi.waitFor` polling: deterministic without the React-flush
// fragility of fake timers under vitest browser mode. The "not called yet"
// checks run synchronously right after `run()` — a real setTimeout cannot fire
// within that microtask gap, so they are not racy.
const DELAY_MS = 50

describe('useDebouncedCallback', () => {
it('runs only the final call in a burst, and only after the quiet period', async () => {
// Arrange
const callback = vi.fn()
const { result } = await renderHook(() =>
useDebouncedCallback(callback, DELAY_MS),
)

// Act — three rapid calls; each restarts the timer.
result.current.run('r')
result.current.run('re')
result.current.run('react')

// Assert — nothing fires synchronously.
expect(callback).not.toHaveBeenCalled()

// Assert — after the quiet period only the final call runs, exactly once.
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1))
expect(callback).toHaveBeenCalledWith('react')
})

it('cancel() drops a scheduled call so it never runs', async () => {
// Arrange
const callback = vi.fn()
const { result } = await renderHook(() =>
useDebouncedCallback(callback, DELAY_MS),
)

// Act — schedule, then immediately cancel before the quiet period elapses.
result.current.run('react')
result.current.cancel()

// Assert — well past the delay, the callback still never fired.
await new Promise((resolve) => setTimeout(resolve, DELAY_MS * 3))
expect(callback).not.toHaveBeenCalled()
})
})
Loading
Loading