diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx index 71ce4422..c02cea89 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx @@ -87,16 +87,20 @@ const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => { return out; }; -type SearchResultsProps = { search: SlskdSearchSummary; onBack: () => void }; +// `initial` is the history row we came from, when there is one. Opening by URL — or landing here the +// moment a search is created — has no row yet, so the summary is fetched like everything else and the +// header fills in a beat later. +type SearchResultsProps = { searchId: string; initial?: SlskdSearchSummary | null; onBack: () => void }; -export const SearchResults = ({ search, onBack }: SearchResultsProps) => { +export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) => { const client = useClient(); const [, bumpRefresh] = usePanelChannel(SLSKD_REFRESH_CHANNEL, 0); - const cachedInit = resultsCache.get(search.id) ?? null; + const cachedInit = resultsCache.get(searchId) ?? null; const [users, setUsers] = useState(cachedInit); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const [isComplete, setIsComplete] = useState(search.isComplete); + const [summary, setSummary] = useState(initial ?? null); + const [isComplete, setIsComplete] = useState(initial?.isComplete ?? false); // Users start expanded; toggling collapses them. const [open, setOpen] = useState>(() => new Set(cachedInit?.map((u) => u.username))); const [queued, setQueued] = useState>(new Set()); @@ -122,12 +126,12 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => { try { // The summary tells us whether to keep polling; tolerate its absence rather than fail the load. const [responses, meta] = await Promise.all([ - client.get(`/slskd/api/v0/searches/${search.id}/responses`), - client.get(`/slskd/api/v0/searches/${search.id}`).catch(() => null), + client.get(`/slskd/api/v0/searches/${searchId}/responses`), + client.get(`/slskd/api/v0/searches/${searchId}`).catch(() => null), ]); if (!aliveRef.current) return; const grouped = groupResponses(responses); - resultsCache.set(search.id, grouped); + resultsCache.set(searchId, grouped); setUsers(grouped); setError(null); setOpen((prev) => { @@ -140,7 +144,10 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => { } return next; }); - if (meta) setIsComplete(meta.isComplete); + if (meta) { + setSummary(meta); + setIsComplete(meta.isComplete); + } } catch (err) { if (aliveRef.current) setError(err instanceof Error ? err.message : String(err)); } finally { @@ -149,21 +156,23 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => { }, // useClient() returns a fresh object each render, so depend only on the search id. // eslint-disable-next-line react-hooks/exhaustive-deps - [search.id], + [searchId], ); useEffect(() => { - const cached = resultsCache.get(search.id) ?? null; + const cached = resultsCache.get(searchId) ?? null; seenRef.current = new Set(cached?.map((u) => u.username) ?? []); pollsRef.current = 0; setUsers(cached); setOpen(new Set(cached?.map((u) => u.username))); setError(null); - setIsComplete(search.isComplete); + // Assume running until the summary says otherwise — a search opened straight after being created has + // no row to tell us, and guessing "complete" would mean never polling for its first responses. + setIsComplete(initial?.id === searchId ? initial.isComplete : false); // Quiet when something is already on screen — no spinner flash over usable results. refresh(Boolean(cached)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [search.id, refresh]); + }, [searchId, refresh]); useEffect(() => { if (isComplete) return; @@ -232,7 +241,7 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
- {search.searchText} + {summary?.searchText ?? initial?.searchText ?? '…'} {!isComplete && ( searching diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx index 8197461d..4cb50438 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx @@ -1,46 +1,111 @@ import { useState, useEffect, useCallback } from 'react'; +import { Link, useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { toast } from 'sonner'; import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react'; import { SearchResults, dropCachedResults } from './SearchResults'; -import { formatWhen, type SlskdSearchSummary } from './shared'; +import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared'; -// The 'search' section — a search input plus the history of past searches (GET /searches). Submitting -// creates a new search on slskd and refreshes the list. (Loading a search's results is a later step.) +// The 'search' section — a search input plus the history of past searches (GET /searches). Which search +// is open lives in `?search=`, so rows are real links and the back button returns to the history. +// +// Submitting opens the new search's results straight away, the way slskd's own web UI does. That is not +// only a nicety: a history row is only as fresh as the last list load, so a search you had just started +// sat at 0 responses looking stalled, while the results view polls for as long as the search runs. + +const HISTORY_POLL_MS = 4000; +// A running search nobody is watching shouldn't poll forever (~3 minutes). +const MAX_HISTORY_POLLS = 45; + +const byNewest = (list: SlskdSearchSummary[]) => [...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt)); + +// slskd accepts a client-supplied id (its own UI depends on that), but don't bet the navigation on it: +// prefer an id the server has actually stored, then fall back to the newest search with this text. +type CreatedIdParams = { list: SlskdSearchSummary[] | null; sentId: string; returnedId?: string; text: string }; +const createdSearchId = ({ list, sentId, returnedId, text }: CreatedIdParams) => { + if (!list) return returnedId ?? sentId; + const known = new Set(list.map((s) => s.id)); + if (returnedId && known.has(returnedId)) return returnedId; + if (known.has(sentId)) return sentId; + return list.find((s) => s.searchText === text)?.id ?? returnedId ?? sentId; +}; export const SearchView = () => { const client = useClient(); + const [params, setParams] = useSearchParams(); const [query, setQuery] = useState(''); const [submitting, setSubmitting] = useState(false); const [history, setHistory] = useState(null); const [error, setError] = useState(null); - const [selected, setSelected] = useState(null); const [clearing, setClearing] = useState(false); const [confirmClear, setConfirmClear] = useState(false); - const load = useCallback(() => { - setError(null); - client - .get('/slskd/api/v0/searches') - .then((list) => setHistory([...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt)))) - .catch((err) => setError(err instanceof Error ? err.message : String(err))); + const selectedId = params.get(SEARCH_PARAM); + + const openSearch = useCallback( + (id: string | null) => + setParams((prev) => { + const next = new URLSearchParams(prev); + if (id) next.set(SEARCH_PARAM, id); + else next.delete(SEARCH_PARAM); + return next; + }), + [setParams], + ); + + const fetchHistory = useCallback(async () => { + const list = await client.get('/slskd/api/v0/searches'); + return byNewest(list ?? []); // useClient() is a fresh object each render — keep this callback stable. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const load = useCallback(() => { + setError(null); + fetchHistory() + .then(setHistory) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + }, [fetchHistory]); + + // Re-read whenever the history is what's on screen — including a browser Back out of a search, which + // no callback of ours sees. useEffect(() => { - load(); - }, [load]); + if (!selectedId) load(); + }, [selectedId, load]); + + // A row's counters only move when the list is re-read, so keep re-reading while a search is running — + // but not while its results are open, since that view polls on its own. + const running = !!history?.some((s) => !s.isComplete); + useEffect(() => { + if (!running || selectedId) return; + let polls = 0; + const timer = setInterval(() => { + polls += 1; + if (polls > MAX_HISTORY_POLLS) { + clearInterval(timer); + return; + } + load(); + }, HISTORY_POLL_MS); + return () => clearInterval(timer); + }, [running, selectedId, load]); const submit = async (ev: React.FormEvent) => { ev.preventDefault(); const text = query.trim(); if (!text || submitting) return; setSubmitting(true); + const sentId = crypto.randomUUID(); try { - await client.post('/slskd/api/v0/searches', { searchText: text }); + const created = await client.post>('/slskd/api/v0/searches', { + id: sentId, + searchText: text, + }); + // A failed reload shouldn't read as a failed search — fall through with what the POST told us. + const list = await fetchHistory().catch(() => null); + if (list) setHistory(list); setQuery(''); - load(); + openSearch(createdSearchId({ list, sentId, returnedId: created?.id, text })); } catch (err) { toast.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`); } finally { @@ -51,6 +116,7 @@ export const SearchView = () => { const remove = async (id: string) => { setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev); dropCachedResults(id); + if (selectedId === id) openSearch(null); client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load()); }; @@ -62,6 +128,7 @@ export const SearchView = () => { setConfirmClear(false); setClearing(true); setHistory([]); + openSearch(null); ids.forEach(dropCachedResults); const settled = await Promise.allSettled(ids.map((id) => client.delete(`/slskd/api/v0/searches/${id}`))); const failed = settled.filter((r) => r.status === 'rejected').length; @@ -78,7 +145,15 @@ export const SearchView = () => { return () => clearTimeout(timer); }, [confirmClear]); - if (selected) return setSelected(null)} />; + // The row we came from, when we have it — a search opened by URL (or just created) resolves its own. + const selected = selectedId ? (history?.find((s) => s.id === selectedId) ?? null) : null; + const hrefFor = (id: string) => { + const next = new URLSearchParams(params); + next.set(SEARCH_PARAM, id); + return `?${next.toString()}`; + }; + + if (selectedId) return openSearch(null)} />; return (
@@ -156,19 +231,13 @@ export const SearchView = () => { {history && history.length > 0 && (
    {history.map((s) => ( -
  • -
    setSelected(s)} - onKeyDown={(ev) => { - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - setSelected(s); - } - }} - className="flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2.5 shadow-sm transition-colors hover:bg-white/5" - > +
  • + {/* The row is an anchor so it opens with cmd-click and keyboard like any link; the remove + button is its sibling, never nested inside it. */} +
    {s.isComplete ? : }
    @@ -186,19 +255,16 @@ export const SearchView = () => {
- - -
+ + + ))} diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index 1e982bd2..771d47ee 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -274,6 +274,10 @@ export const SOULSEEK_USER_CHANNEL = 'soulseek:user'; export type SoulseekUserRequest = { username: string; nonce: number }; // A past search, as listed by GET /searches (no responses inlined). +// Which search's results are open — `/soulseek?search=`, read from the URL rather than held in a +// panel channel or local state. See docs/navigation-audit.md. +export const SEARCH_PARAM = 'search'; + export type SlskdSearchSummary = { id: string; searchText: string;