feat: upgrade CommandPalette with filters, grouping, and pagination (#126)

* feat: upgrade CommandPalette with filters, grouping, and pagination

Rewrite the Cmd+K search modal as a full-featured search experience:

- Filter chips: collection and status filters from facets, toggle on click
- Grouped results: items grouped by collection with section headers
- Better result cards: ref, title, priority dot, status badge, relative date
- Pagination: "Load more" button appends next page of results
- Recent searches: last 10 queries persisted in localStorage
- Result count displayed below filter chips

* fix: reset loading state on empty query and guard stale loadMore

- Clear loading flag when query is emptied so spinner doesn't stick
- Capture query/filter snapshot before loadMore API call and discard
  the response if they changed while loading

Addresses codex review on PR #126.
This commit is contained in:
xarmian
2026-04-15 08:10:04 -04:00
committed by GitHub
parent 8351b8194f
commit 9616374a80
@@ -2,60 +2,171 @@
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { collectionStore } from '$lib/stores/collections.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
import type { SearchResult } from '$lib/types';
import type { SearchResult, SearchFacets, SearchFilters } from '$lib/types';
import { getFieldValue, itemUrlId, formatItemRef } from '$lib/types';
import { relativeTime } from '$lib/utils/markdown';
const RECENT_SEARCHES_KEY = 'pad-recent-searches';
const MAX_RECENT = 10;
const PAGE_SIZE = 20;
let query = $state('');
let results = $state<SearchResult[]>([]);
let total = $state(0);
let facets = $state<SearchFacets | undefined>(undefined);
let selectedIdx = $state(0);
let loading = $state(false);
let loadingMore = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
let inputEl = $state<HTMLInputElement>();
// Filters
let filterCollection = $state<string | null>(null);
let filterStatus = $state<string | null>(null);
// Recent searches
let recentSearches = $state<string[]>(loadRecentSearches());
// Derived: whether any filter is active
let hasFilters = $derived(filterCollection !== null || filterStatus !== null);
// Derived: group results by collection when not filtering by collection
let groupedResults = $derived.by(() => {
if (filterCollection || results.length === 0) return null;
const groups: Record<string, { icon: string; name: string; results: SearchResult[] }> = {};
for (const r of results) {
const slug = r.item.collection_slug || 'unknown';
if (!groups[slug]) {
const coll = collectionStore.collections.find((c) => c.slug === slug);
groups[slug] = {
icon: r.item.collection_icon || coll?.icon || '📦',
name: coll?.name || slug,
results: []
};
}
groups[slug].results.push(r);
}
return groups;
});
// Derived: flat index list for keyboard navigation
let flatResults = $derived(results);
$effect(() => {
if (uiStore.searchOpen) {
requestAnimationFrame(() => inputEl?.focus());
} else {
query = '';
results = [];
total = 0;
facets = undefined;
selectedIdx = 0;
filterCollection = null;
filterStatus = null;
loading = false;
}
});
function buildFilters(offset = 0): SearchFilters {
const filters: SearchFilters = {
workspace: workspaceStore.current?.slug,
limit: PAGE_SIZE,
offset
};
if (filterCollection) filters.collection = filterCollection;
if (filterStatus) filters.status = filterStatus;
return filters;
}
function doSearch() {
clearTimeout(searchTimeout);
if (!query.trim()) {
results = [];
total = 0;
facets = undefined;
selectedIdx = 0;
loading = false;
return;
}
loading = true;
searchTimeout = setTimeout(async () => {
const ws = workspaceStore.current?.slug;
try {
const resp = await api.search(query, { workspace: ws });
const resp = await api.search(query, buildFilters(0));
results = resp.results;
total = resp.total;
facets = resp.facets;
selectedIdx = 0;
} catch {
results = [];
total = 0;
facets = undefined;
} finally {
loading = false;
}
}, 200);
}
async function loadMore() {
if (loadingMore || results.length >= total) return;
loadingMore = true;
const snapshotQuery = query;
const snapshotCollection = filterCollection;
const snapshotStatus = filterStatus;
try {
const resp = await api.search(query, buildFilters(results.length));
// Discard if query or filters changed while loading
if (query !== snapshotQuery || filterCollection !== snapshotCollection || filterStatus !== snapshotStatus) return;
results = [...results, ...resp.results];
} catch {
// ignore
} finally {
loadingMore = false;
}
}
function applyFilter(type: 'collection' | 'status', value: string) {
if (type === 'collection') {
filterCollection = filterCollection === value ? null : value;
} else {
filterStatus = filterStatus === value ? null : value;
}
doSearch();
}
function clearFilters() {
filterCollection = null;
filterStatus = null;
doSearch();
}
function scrollSelectedIntoView() {
requestAnimationFrame(() => {
const el = document.querySelector('.result.selected');
el?.scrollIntoView({ block: 'nearest' });
});
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
uiStore.closeSearch();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIdx = Math.min(selectedIdx + 1, results.length - 1);
selectedIdx = Math.min(selectedIdx + 1, flatResults.length - 1);
scrollSelectedIntoView();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx = Math.max(selectedIdx - 1, 0);
} else if (e.key === 'Enter' && results.length > 0) {
scrollSelectedIntoView();
} else if (e.key === 'Enter' && flatResults.length > 0) {
e.preventDefault();
selectResult(results[selectedIdx]);
selectResult(flatResults[selectedIdx]);
}
}
function selectResult(r: SearchResult) {
saveRecentSearch(query.trim());
const ws = workspaceStore.current?.slug;
const wsUsername = workspaceStore.current?.owner_username;
const collSlug = r.item.collection_slug;
@@ -65,18 +176,72 @@
uiStore.closeSearch();
}
function useRecentSearch(q: string) {
query = q;
doSearch();
requestAnimationFrame(() => inputEl?.focus());
}
function stripHtml(s: string): string {
return s.replace(/<[^>]*>/g, '');
}
function statusColor(status: string): string {
const s = status?.toLowerCase().replace(/-/g, '_');
if (['done', 'completed', 'fixed', 'implemented', 'resolved'].includes(s)) return 'var(--accent-green)';
if (['done', 'completed', 'fixed', 'implemented', 'resolved'].includes(s))
return 'var(--accent-green)';
if (['in_progress', 'exploring', 'fixing'].includes(s)) return 'var(--accent-amber)';
if (['open', 'new', 'draft', 'todo', 'planned'].includes(s)) return 'var(--accent-blue)';
if (s === 'active') return 'var(--accent-cyan)';
return 'var(--text-muted)';
}
function priorityDot(priority: string): string {
const p = priority?.toLowerCase();
if (p === 'critical') return '\u{1F534}';
if (p === 'high') return '\u{1F7E0}';
if (p === 'medium') return '\u{1F7E1}';
if (p === 'low') return '\u26AA';
return '';
}
// Recent searches persistence
function loadRecentSearches(): string[] {
try {
const raw = localStorage.getItem(RECENT_SEARCHES_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveRecentSearch(q: string) {
if (!q) return;
const filtered = recentSearches.filter((s) => s !== q);
recentSearches = [q, ...filtered].slice(0, MAX_RECENT);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(recentSearches));
} catch {
// ignore
}
}
function clearRecentSearches() {
recentSearches = [];
try {
localStorage.removeItem(RECENT_SEARCHES_KEY);
} catch {
// ignore
}
}
function renderResultCard(r: SearchResult, i: number): { ref: string | null; status: string | undefined; priority: string | undefined } {
return {
ref: formatItemRef(r.item),
status: getFieldValue(r.item, 'status'),
priority: getFieldValue(r.item, 'priority')
};
}
</script>
{#if uiStore.searchOpen}
@@ -84,8 +249,23 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={() => uiStore.closeSearch()}>
<div class="palette" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
<!-- Search input -->
<div class="search-row">
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<svg
class="search-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="11" cy="11" r="8" /><line
x1="21"
y1="21"
x2="16.65"
y2="16.65"
/></svg
>
<input
bind:this={inputEl}
bind:value={query}
@@ -93,44 +273,214 @@
placeholder="Search items, collections, docs..."
class="search-input"
/>
{#if loading}
<span class="search-spinner"></span>
{/if}
<kbd class="search-hint">esc</kbd>
</div>
<!-- Filter chips -->
{#if facets && query.trim()}
<div class="filters-row">
<div class="filter-chips">
{#if facets.collections && Object.keys(facets.collections).length > 0}
{#each Object.entries(facets.collections) as [slug, count] (slug)}
{@const coll = collectionStore.collections.find((c) => c.slug === slug)}
<button
class="filter-chip"
class:active={filterCollection === slug}
onclick={() => applyFilter('collection', slug)}
>
<span class="chip-icon">{coll?.icon || '📦'}</span>
<span class="chip-label">{coll?.name || slug}</span>
<span class="chip-count">{count}</span>
</button>
{/each}
{/if}
{#if facets.statuses && Object.keys(facets.statuses).length > 0}
{#each Object.entries(facets.statuses) as [status, count] (status)}
<button
class="filter-chip status-chip"
class:active={filterStatus === status}
onclick={() => applyFilter('status', status)}
>
<span
class="chip-dot"
style="background: {statusColor(status)};"
></span>
<span class="chip-label">{status.replace(/_/g, ' ')}</span>
<span class="chip-count">{count}</span>
</button>
{/each}
{/if}
</div>
{#if hasFilters}
<button class="clear-filters" onclick={clearFilters}>Clear filters</button>
{/if}
</div>
{/if}
<!-- Result count -->
{#if results.length > 0 && query.trim()}
<div class="result-count">
{total} result{total === 1 ? '' : 's'}
</div>
{/if}
<!-- Results -->
{#if results.length > 0}
<div class="results">
{#each results as r, i}
{@const ref = formatItemRef(r.item)}
{@const status = getFieldValue(r.item, 'status')}
<button
class="result"
class:selected={i === selectedIdx}
onclick={() => selectResult(r)}
>
<div class="result-main">
<span class="result-icon">{r.item.collection_icon || '📦'}</span>
{#if ref}
<span class="result-ref">{ref}</span>
{/if}
<span class="result-title">{r.item.title}</span>
{#if status}
<span class="result-status" style="background: color-mix(in srgb, {statusColor(status)} 15%, transparent); color: {statusColor(status)};">
{status.replace(/_/g, ' ')}
</span>
{/if}
{#if groupedResults && !filterCollection}
<!-- Grouped by collection -->
{#each Object.entries(groupedResults) as [slug, group] (slug)}
<div class="result-group">
<div class="group-header">
<span class="group-icon">{group.icon}</span>
<span class="group-name">{group.name}</span>
<span class="group-count">{group.results.length}</span>
</div>
{#each group.results as r (r.item.id || r.item.slug)}
{@const idx = flatResults.indexOf(r)}
{@const meta = renderResultCard(r, idx)}
<button
class="result"
class:selected={idx === selectedIdx}
onclick={() => selectResult(r)}
>
<div class="result-main">
<span class="result-icon"
>{r.item.collection_icon || '📦'}</span
>
{#if meta.ref}
<span class="result-ref">{meta.ref}</span>
{/if}
<span class="result-title">{r.item.title}</span>
{#if meta.priority}
{@const dot = priorityDot(meta.priority)}
{#if dot}
<span class="result-priority" title={meta.priority}
>{dot}</span
>
{/if}
{/if}
{#if meta.status}
<span
class="result-status"
style="background: color-mix(in srgb, {statusColor(meta.status)} 15%, transparent); color: {statusColor(meta.status)};"
>
{meta.status.replace(/_/g, ' ')}
</span>
{/if}
{#if r.item.updated_at}
<span class="result-date"
>{relativeTime(r.item.updated_at)}</span
>
{/if}
</div>
{#if r.snippet}
<div class="result-snippet">
{stripHtml(r.snippet)}
</div>
{/if}
</button>
{/each}
</div>
{#if r.snippet}
<div class="result-snippet">{stripHtml(r.snippet)}</div>
{/if}
</button>
{/each}
{/each}
{:else}
<!-- Flat list (filtered by collection or no grouping) -->
{#each results as r, i (r.item.id || r.item.slug)}
{@const meta = renderResultCard(r, i)}
<button
class="result"
class:selected={i === selectedIdx}
onclick={() => selectResult(r)}
>
<div class="result-main">
<span class="result-icon"
>{r.item.collection_icon || '📦'}</span
>
{#if meta.ref}
<span class="result-ref">{meta.ref}</span>
{/if}
<span class="result-title">{r.item.title}</span>
{#if meta.priority}
{@const dot = priorityDot(meta.priority)}
{#if dot}
<span class="result-priority" title={meta.priority}
>{dot}</span
>
{/if}
{/if}
{#if meta.status}
<span
class="result-status"
style="background: color-mix(in srgb, {statusColor(meta.status)} 15%, transparent); color: {statusColor(meta.status)};"
>
{meta.status.replace(/_/g, ' ')}
</span>
{/if}
{#if r.item.updated_at}
<span class="result-date"
>{relativeTime(r.item.updated_at)}</span
>
{/if}
</div>
{#if r.snippet}
<div class="result-snippet">{stripHtml(r.snippet)}</div>
{/if}
</button>
{/each}
{/if}
<!-- Load more -->
{#if results.length < total}
<div class="load-more-row">
<button class="load-more-btn" onclick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading...' : `Load more (${total - results.length} remaining)`}
</button>
</div>
{/if}
</div>
{:else if query.trim()}
{:else if query.trim() && !loading}
<div class="no-results">No results for "{query}"</div>
{:else}
<div class="search-tips">
<span class="tip-label">Try searching for</span>
<span class="tip-example">task names, ideas, docs, or any text</span>
</div>
{:else if !query.trim()}
<!-- Recent searches or tips -->
{#if recentSearches.length > 0}
<div class="recent-searches">
<div class="recent-header">
<span class="recent-label">Recent searches</span>
<button class="clear-recent" onclick={clearRecentSearches}
>Clear recent</button
>
</div>
{#each recentSearches as recent, i (recent)}
<button
class="recent-item"
class:selected={i === selectedIdx}
onclick={() => useRecentSearch(recent)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="recent-icon"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
<span class="recent-text">{recent}</span>
</button>
{/each}
</div>
{:else}
<div class="search-tips">
<span class="tip-label">Try searching for</span>
<span class="tip-example">task names, ideas, docs, or any text</span>
</div>
{/if}
{/if}
</div>
</div>
@@ -144,17 +494,17 @@
z-index: 50;
display: flex;
justify-content: center;
padding-top: 15vh;
padding-top: 12vh;
}
.palette {
width: 100%;
max-width: 560px;
max-width: 640px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
max-height: 60vh;
max-height: 65vh;
display: flex;
flex-direction: column;
}
@@ -164,6 +514,7 @@
gap: var(--space-3);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.search-icon {
color: var(--text-muted);
@@ -180,6 +531,20 @@
.search-input:focus {
border: none;
}
.search-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--text-muted);
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.search-hint {
font-size: 0.7em;
color: var(--text-muted);
@@ -190,18 +555,125 @@
font-family: var(--font-mono);
flex-shrink: 0;
}
/* Filter chips */
.filters-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
flex: 1;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75em;
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.filter-chip:hover {
background: var(--bg-hover);
}
.filter-chip.active {
background: color-mix(in srgb, var(--accent-blue) 20%, transparent);
border-color: var(--accent-blue);
color: var(--accent-blue);
}
.chip-icon {
font-size: 1em;
}
.chip-label {
text-transform: capitalize;
}
.chip-count {
color: var(--text-muted);
font-size: 0.9em;
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.clear-filters {
font-size: 0.72em;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius);
flex-shrink: 0;
}
.clear-filters:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
/* Result count */
.result-count {
padding: var(--space-1) var(--space-4);
font-size: 0.75em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Results */
.results {
overflow-y: auto;
padding: var(--space-2);
}
/* Group headers */
.result-group {
margin-bottom: var(--space-2);
}
.group-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: 0.75em;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.group-icon {
font-size: 1.1em;
}
.group-name {
flex: 1;
}
.group-count {
font-weight: 400;
}
/* Result cards */
.result {
display: block;
width: 100%;
text-align: left;
padding: var(--space-3) var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
}
.result:hover, .result.selected {
.result:hover,
.result.selected {
background: var(--bg-hover);
}
.result-main {
@@ -209,14 +681,28 @@
align-items: center;
gap: var(--space-2);
}
.result-icon { font-size: 1em; flex-shrink: 0; }
.result-icon {
font-size: 1em;
flex-shrink: 0;
}
.result-ref {
font-family: var(--font-mono);
font-size: 0.75em;
color: var(--text-muted);
flex-shrink: 0;
}
.result-title { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-title {
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-priority {
font-size: 0.8em;
flex-shrink: 0;
line-height: 1;
}
.result-status {
font-size: 0.7em;
padding: 2px 8px;
@@ -224,6 +710,12 @@
flex-shrink: 0;
text-transform: capitalize;
}
.result-date {
font-size: 0.7em;
color: var(--text-muted);
flex-shrink: 0;
white-space: nowrap;
}
.result-snippet {
font-size: 0.85em;
color: var(--text-muted);
@@ -233,6 +725,31 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Load more */
.load-more-row {
padding: var(--space-2) var(--space-3);
text-align: center;
}
.load-more-btn {
font-size: 0.8em;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius);
cursor: pointer;
width: 100%;
}
.load-more-btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.load-more-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* No results / tips */
.no-results {
padding: var(--space-4);
text-align: center;
@@ -253,4 +770,60 @@
font-size: 0.85em;
color: var(--text-secondary);
}
/* Recent searches */
.recent-searches {
padding: var(--space-2);
}
.recent-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) var(--space-3);
margin-bottom: var(--space-1);
}
.recent-label {
font-size: 0.75em;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.clear-recent {
font-size: 0.72em;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius);
}
.clear-recent:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.recent-item {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
font-size: 0.9em;
color: var(--text-secondary);
}
.recent-item:hover,
.recent-item.selected {
background: var(--bg-hover);
}
.recent-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.recent-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>