diff --git a/docs/navigation-audit.md b/docs/navigation-audit.md index af3c352d..82d18ca0 100644 --- a/docs/navigation-audit.md +++ b/docs/navigation-audit.md @@ -36,7 +36,7 @@ That fix is the template for the HIGH items below. **Caveat:** the fix only did ## Route map (from `App.tsx`) **Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/projects/:id`, `/email/:emailId`. -**Flat screens:** `/` `/files` `/music` `/soulseek` `/tasks` `/skills` `/processes` `/activity` +**Flat screens:** `/` `/files` `/music` `/tasks` `/skills` `/processes` `/activity` `/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/browser` `/code-editor`. The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}` plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list @@ -87,12 +87,13 @@ is **one design decision** that cascades across many files: - **Music** — `Music/{MusicBrowser:182,209 · MusicDetail:138,174,234 · FavoritesView:85,107}` hold the entire library location in `usePanelChannel('music:cwd')`; every drill-in is `setCwd(path)`. No URL, no deep-link to an album. → back `music:cwd` with `/music?path=`; rows become ``s. -- **Soulseek** — an entire workspace with **no URL at all**: `soulseek:section` nav (`SoulseekNav:40`, - `SoulseekDashboard:88/96/110/121`), `soulseek:user` peer channel (`UserMenu:32`, `SoulseekUsers:42`), and - per-panel `useState` for past-search (`SearchView:160`, a textbook `role="button"` div with `s.id` in the - closure), rooms (`SoulseekRooms:153`), conversations (`SoulseekChat:135`). → decide a nested tree - `/soulseek/:section` + `/soulseek/{users/:name, search/:id, rooms/:name, chat/:name}`. **S1 (the section nav) - decides S2–S7.** +- ~~**Soulseek**~~ — **mostly done.** The section is `/soulseek/:section` (nav entries are `NavLink`s, the + view panel reads the same URL), the peer is `?user=`, and the past-search was already `?search=`. + The `soulseek:section` and `soulseek:user` channels are deleted; `soulseek:refresh` stays, which is a + signal and not selection. The peer went in the query string rather than `/soulseek/users/:name` as + sketched here, because the section nav has to stay a one-segment `NavLink` — a second path segment would + need a nested route just to keep the highlight, and the query string is what `?search=` already uses. + Still on `useState`: rooms (`SoulseekRooms`) and conversations (`SoulseekChat`). ### 🟡 LOW / borderline @@ -120,8 +121,6 @@ This is the primary surface to convert to URL-driven selection. | `SELECTED_PROJECT` (`useGlobal`) | selected project | `/projects/:id` | `ProjectListApp.tsx:45`, `ProjectPreview.tsx:386` (H3) | | `EMAIL_SELECTED` | open email | `/email/:emailId` | `EmailList.tsx:336`, `EmailScreen.tsx` sync (H5) | | `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` | -| `soulseek:section` | active Soulseek section | `/soulseek/:section` | `SoulseekNav/View/Dashboard/UserMenu` | -| `soulseek:user` | selected peer | `/soulseek/users/:name` | `UserMenu.tsx:28`, `SoulseekUsers.tsx:34` | | local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` | **Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`, @@ -201,7 +200,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri ### Phase 3 — Whole-workspace routing decisions (needs a design call first) - [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `` (M-music). -- [ ] **Soulseek** — decide the nested tree `/soulseek/:section` + `/soulseek/{users,search,rooms,chat}/:x`; **S1 decides S2–S7** (M-slsk). +- [x] **Soulseek** — `/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.** - [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.** - [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`). diff --git a/docs/workspace-panel-todo.md b/docs/workspace-panel-todo.md index 49a7dd67..249e9de2 100644 --- a/docs/workspace-panel-todo.md +++ b/docs/workspace-panel-todo.md @@ -646,8 +646,16 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin key now, so there is nothing left to disagree with. **SystemMonitor is done too** (audit M10) — `/system-monitor/:scope`, scope list as `NavLink`s, `monitor:scope` deleted. Smallest of the seven and the same shape as `/photos`. - Remaining here: Music, Soulseek, Email, Browser. **Chat detail (`chat:selected-session`) is - chat-owned — leave it; it is written up in `COMMS/`.** + **Soulseek is done** — `/soulseek/:section`, and the peer moved to `?user=` rather than the + `/soulseek/users/:name` the audit sketched: a second path segment would have needed a nested route + just to keep the nav's `NavLink` highlight, and `?search=` was already establishing the convention + for that workspace. Deleting `soulseek:user` also deleted the nonce it carried — a request stamped + `{username, nonce}`, consumed-once by the Users panel so that returning to the section did not + re-run the lookup, and nonce-stamped so that asking for the *same* peer twice still counted twice. + A link needs none of that: it is idempotent, so there is nothing to consume and nothing to + disambiguate. That is the shape of most of this section's remaining work. + Remaining here: Music, Email, Browser. **Chat detail (`chat:selected-session`) is chat-owned — + leave it; it is written up in `COMMS/`.** ### 5.9 The context has grown an app-config section — _(found 2026-08-07)_ diff --git a/docs/workspace-panels.md b/docs/workspace-panels.md index 04e421c0..b236faa5 100644 --- a/docs/workspace-panels.md +++ b/docs/workspace-panels.md @@ -126,10 +126,11 @@ connection status and the header renders it. Panel-scoped by construction, which ### The thirteen channels, honestly -13 channels, 26 call sites. Six carry selection that should be in the URL — `music:cwd` (the entire -library location, so no album is linkable), `soulseek:section` and `soulseek:user` (the whole Soulseek -workspace has no URL), `chat:selected-session`, `monitor:scope`. Two are view toggles where a channel is -genuinely fine (`music:favorites`, `music:lyrics`). +Was 13 channels, 26 call sites. Six carried selection that belongs in the URL, and three of those are +gone: `soulseek:section` and `soulseek:user` (the whole Soulseek workspace now has one) and +`monitor:scope`. Still to move: `music:cwd` — the entire library location, so no album is linkable — and +`chat:selected-session`, which is the chat's to move, not this document's. Two are view toggles where a +channel is genuinely fine (`music:favorites`, `music:lyrics`). Two are wired to nobody: `preview:refresh` and `chat:active-session` have publishers and no subscribers. A third, `file-viewer:`, had a subscriber and no publisher — it is gone, along with the diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 5b7c5f1c..4daf55c8 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -59,6 +59,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx index 22748f88..3b193752 100644 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx @@ -1,17 +1,22 @@ +import { Navigate, useParams } from 'react-router'; import type { LayoutNode } from 'officerdev'; -import { WorkspaceView } from 'officerdev'; +import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulseekSection } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /soulseek uses the Workspace/Panel system (like /chat and /music): a section nav (soulseek-nav) on -// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section' -// channel. Both talk to slskd through the /api/slskd auth proxy. +// /soulseek uses the Workspace/Panel system (like /photos): a section nav (soulseek-nav) on the left and +// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection — +// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy. export const SoulseekScreen = () => { + const { section } = useParams(); // v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted // layout doesn't resurrect as two mismatched panels. const workspace = useDashboardState('screens/soulseek-v2', defaultLayout); + // After the hooks — it returns early. + if (!isSoulseekSection(section)) return ; + return (
{ export const SoulseekDashboard = () => { const client = useClient(); - const [, setSection] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); const [app, setApp] = useState(null); const [downloads, setDownloads] = useState(emptyTally); const [uploads, setUploads] = useState(emptyTally); @@ -85,7 +85,7 @@ export const SoulseekDashboard = () => {
{/* Downloads */} -
} onClick={() => setSection('downloads')}> +
} to={soulseekSectionPath('downloads')}> @@ -93,7 +93,7 @@ export const SoulseekDashboard = () => {
{/* Uploads */} -
} onClick={() => setSection('uploads')}> +
} to={soulseekSectionPath('uploads')}> @@ -105,29 +105,27 @@ export const SoulseekDashboard = () => {
Recent searches - +
{recent.length === 0 ? (

No searches yet.

) : (
{recent.map((s) => ( - + ))}
)} @@ -137,19 +135,18 @@ export const SoulseekDashboard = () => { ); }; -type SectionProps = { title: string; icon: React.ReactNode; onClick: () => void; children: React.ReactNode }; +type SectionProps = { title: string; icon: React.ReactNode; to: string; children: React.ReactNode }; -const Section = ({ title, icon, onClick, children }: SectionProps) => ( +const Section = ({ title, icon, to, children }: SectionProps) => (
- +
{children}
); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx index 1fb04af4..5b2ba11f 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { NavLink } from 'react-router'; import { LayoutGrid, Search, @@ -11,10 +11,11 @@ import { Server, Plug, } from 'lucide-react'; -import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared'; +import { SOULSEEK_SECTIONS, soulseekSectionPath, type SoulseekSectionId } from './shared'; -// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes -// the active section to the 'soulseek:section' channel; SoulseekView (right) renders the matching UI. +// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Entries are +// react-router NavLinks to /soulseek/
, so active state comes from the router and SoulseekView +// (right) learns which section is open by reading the same URL rather than by being told. const ICONS: Record = { dashboard: LayoutGrid, @@ -29,8 +30,6 @@ const ICONS: Record = { }; export const SoulseekNav = () => { - const [section, setSection] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); - return (
@@ -46,26 +45,30 @@ export const SoulseekNav = () => { diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx index 77b4cffc..5ae02393 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx @@ -1,19 +1,20 @@ import { useState, useEffect } from 'react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useSearchParams } from 'react-router'; import { toast } from 'sonner'; import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react'; import { useSoulseekFavorites } from './useSoulseekFavorites'; import { useSoulseekUser } from './useSoulseekUser'; import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse'; import { SharesBrowser, BrowseStateChip } from './SharesBrowser'; -import { SOULSEEK_USER_CHANNEL, type SoulseekUserRequest } from './shared'; +import { USER_PARAM } from './shared'; // Users panel — look up a peer: their presence (online/away/offline) and profile info (description, // upload slots, queue), both cheap live calls (GET /users/{u}/status + /info). Their shared folders come // from Officer's own cache instead, via — see that file for why it isn't browsed live. // // This is also where the workspace's peer actions land: the username dropdown in search results and -// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up. +// downloads links here with `?user=`, which is the whole mechanism — a peer profile is now +// linkable and survives a reload. // Favourites — Officer's own data, since slskd has no such concept — get their own section at the top, // which also serves as this panel's landing content before any lookup. @@ -29,9 +30,9 @@ const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof C }; export const SoulseekUsers = () => { - const [query, setQuery] = useState(''); - const [selected, setSelected] = useState(''); - const [request, setRequest] = usePanelChannel(SOULSEEK_USER_CHANNEL, null); + const [params, setParams] = useSearchParams(); + const selected = params.get(USER_PARAM)?.trim() ?? ''; + const [query, setQuery] = useState(selected); const { favorites, isFavorite, toggle } = useSoulseekFavorites(); const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots(); @@ -39,23 +40,22 @@ export const SoulseekUsers = () => { const user = useSoulseekUser(selected); const peer = user.data ?? null; + // Navigating, not setting state: the peer is `?user=`. Shares are still NOT fetched automatically — + // that's a multi-minute server-side job, so it stays an explicit click in the shares card. const select = (name: string) => { const username = name.trim(); if (!username) return; - setQuery(username); - setSelected(username); + setParams((prev) => { + const next = new URLSearchParams(prev); + next.set(USER_PARAM, username); + return next; + }); }; - // Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and - // back doesn't re-run the lookup. Shares are NOT fetched automatically — that's a multi-minute - // server-side job, so it stays an explicit click in the shares card. + // Arriving by link (or Back) should show the name in the box that produced it. useEffect(() => { - if (!request) return; - setRequest(null); - select(request.username); - // select is recreated each render; the effect deliberately runs only when a request arrives. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [request]); + if (selected) setQuery(selected); + }, [selected]); useEffect(() => { if (user.error) toast.error(user.error.message); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx index 4badd9fe..b32716e9 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx @@ -1,6 +1,5 @@ import { Construction } from 'lucide-react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared'; +import { SOULSEEK_SECTIONS, useSoulseekSection, type SoulseekSectionId } from './shared'; import { SearchView } from './SearchView'; import { SoulseekTransfers } from './SoulseekTransfers'; import { SoulseekUploads } from './SoulseekUploads'; @@ -12,7 +11,7 @@ import { SoulseekSystem } from './SoulseekSystem'; import { SoulseekConnection } from './SoulseekConnection'; import { useServiceConnection } from '../../hooks/useServiceConnection'; -// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. Content +// Right panel of the /soulseek workspace — renders the UI for the section the URL names. Content // zoom used to live here; it is now a framework-level per-panel control in PanelSlot. const Placeholder = ({ id }: { id: SoulseekSectionId }) => { @@ -56,7 +55,7 @@ const sectionView = (section: SoulseekSectionId) => { }; export const SoulseekView = () => { - const [section] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); + const section = useSoulseekSection(); const { data: connection, isLoading } = useServiceConnection('slskd'); // Nothing connected yet: the setup form takes over every section, because none of them can do anything diff --git a/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx index 7939cd71..f92661bf 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx @@ -1,4 +1,4 @@ -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { Link } from 'react-router'; import { toast } from 'sonner'; import { FolderOpen, Star, StarOff } from 'lucide-react'; import { @@ -10,30 +10,19 @@ import { DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { useSoulseekFavorites } from './useSoulseekFavorites'; -import { - SOULSEEK_SECTION_CHANNEL, - SOULSEEK_USER_CHANNEL, - type SoulseekSectionId, - type SoulseekUserRequest, -} from './shared'; +import { soulseekUserPath } from './shared'; // The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace -// and act on that peer. Opening a peer hands the username to the Users section over a panel channel -// (which owns the lookup and the cached share tree); favouriting goes to the sidecar's favourites route. +// and act on that peer. Opening a peer is a real link to /soulseek/users?user= — the Users section +// still owns the lookup and the cached share tree, it just reads who from the URL. Favouriting is a +// mutation, so it stays a menu item that acts rather than navigates. type UserMenuProps = { username: string }; export const UserMenu = ({ username }: UserMenuProps) => { - const [, setSection] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); - const [, setRequest] = usePanelChannel(SOULSEEK_USER_CHANNEL, null); const { isFavorite, toggle } = useSoulseekFavorites(); const favorited = isFavorite(username); - const openPeer = () => { - setRequest({ username, nonce: Date.now() }); - setSection('users'); - }; - const toggleFavorite = () => { toggle(username); toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`); @@ -54,9 +43,11 @@ export const UserMenu = ({ username }: UserMenuProps) => { {username} - - - View profile & shares + + + + View profile & shares + {favorited ? : } diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index f4aa558c..9ee73ac8 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -1,5 +1,8 @@ -// Shared types/helpers for the /soulseek workspace panels (SoulseekSearch + SoulseekTransfers), which -// talk to slskd through the /api/slskd auth proxy and coordinate over a single panel channel. +// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/slskd +// auth proxy. What is open — the section, the peer, the search — is the URL; the one channel left here +// is a refresh signal, which is what channels are for. + +import { useParams } from 'react-router'; // Bumped (to a fresh nonce) whenever a download is enqueued from the search panel, so the transfers // panel refetches immediately instead of waiting for its next poll tick. @@ -235,9 +238,8 @@ export type SlskdRoomMessage = { export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] }; export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean }; -// The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads -// it and renders the matching UI. Mirrors slskd's own top-menu sections. -export const SOULSEEK_SECTION_CHANNEL = 'soulseek:section'; +// Which section is open is `/soulseek/:section` — the nav panel and the view panel both read the URL and +// neither publishes to the other. Mirrors slskd's own top-menu sections. export type SoulseekSectionId = | 'dashboard' | 'search' @@ -260,14 +262,34 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [ { id: 'connection', label: 'Connection' }, ]; -// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users -// section. Nonce-stamped and consumed-once (the Users panel clears it) so revisiting the section doesn't -// re-run the lookup, while two requests for the SAME username still each trigger a fresh one. -export const SOULSEEK_USER_CHANNEL = 'soulseek:user'; -export type SoulseekUserRequest = { username: string; nonce: number }; +/** Where /soulseek lands, and where an unrecognised section redirects to. */ +export const DEFAULT_SOULSEEK_SECTION: SoulseekSectionId = 'dashboard'; + +export const isSoulseekSection = (value: string | undefined): value is SoulseekSectionId => + SOULSEEK_SECTIONS.some((s) => s.id === value); + +export const soulseekSectionPath = (section: SoulseekSectionId) => `/soulseek/${section}`; + +// SoulseekScreen redirects anything unrecognised, so the fallback here only covers the instant before +// that lands. +export const useSoulseekSection = (): SoulseekSectionId => { + const { section } = useParams(); + return isSoulseekSection(section) ? section : DEFAULT_SOULSEEK_SECTION; +}; + +// Which peer the Users section is showing — `/soulseek/users?user=`. +// +// This was a `soulseek:user` channel carrying `{username, nonce}`: the username dropdown published a +// request, the Users panel consumed it once and cleared it, and the nonce existed so that asking for the +// SAME peer twice still counted as two requests. All of that machinery was a workaround for the peer not +// being in the URL — a link is idempotent, so there is nothing to consume and no nonce to disambiguate. +export const USER_PARAM = 'user'; + +export const soulseekUserPath = (username: string) => + `${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`; // 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 +// Which search's results are open — `/soulseek/search?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'; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index a8dd2596..d0f404ef 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -43,6 +43,15 @@ export type { PhotosSectionId } from './apps/Photos/shared'; export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared'; export type { JellyfinSectionId } from './apps/Jellyfin/shared'; +// Same for /soulseek, which also puts the open peer in `?user=`. +export { + DEFAULT_SOULSEEK_SECTION, + soulseekSectionPath, + soulseekUserPath, + isSoulseekSection, +} from './apps/Soulseek/shared'; +export type { SoulseekSectionId } from './apps/Soulseek/shared'; + // Same for /system-monitor, where the param is the scope rather than a section. export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared'; export type { MonitorScope } from './apps/SystemMonitor/shared';