put the monitor scope in the url

/system-monitor/:scope, the same shape as /photos: route pair, one Navigate guard after the
hooks, scope list as react-router NavLinks, and both panels reading useParams instead of
agreeing over a `monitor:scope` channel. The Dock's isActive is a startsWith, so its
highlight survives the redirect off the bare route.
This commit is contained in:
2026-08-07 11:41:16 +00:00
parent 2502c33804
commit 00332e275a
8 changed files with 66 additions and 41 deletions
+2 -3
View File
@@ -79,7 +79,7 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
| M7 | `workspaces/components/Combobox.tsx:53` | caller-supplied route | (existing) | shared widget: `href`-bearing options do `navigate(option.href)`; render them as `<Link>`. Every caller inherits the opaque click. | | M7 | `workspaces/components/Combobox.tsx:53` | caller-supplied route | (existing) | shared widget: `href`-bearing options do `navigate(option.href)`; render them as `<Link>`. Every caller inherits the opaque click. |
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. | | ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
| M9 | `Screens/Dashboard/Plans/index.tsx:36` | a plan document | `/plans/:name` | native `<select>` → local state; a plan is a real addressable doc. Have the select `navigate()` or use a link list. | | M9 | `Screens/Dashboard/Plans/index.tsx:36` | a plan document | `/plans/:name` | native `<select>` → local state; a plan is a real addressable doc. Have the select `navigate()` or use a link list. |
| M10 | `SystemMonitor/ScopeList.tsx:14` | monitor scope (btop/pm2/docker) | `/system-monitor/:scope` | small fixed set; low urgency but genuinely addressable. | | ~~M10~~ | `SystemMonitor/ScopeList.tsx` | monitor scope (btop/pm2/docker) | `/system-monitor/:scope` | **Done.** Route pair + `Navigate` guard; the scope buttons are `NavLink`s and `useMonitorScope` reads `useParams` instead of the channel. The Dock's hand-rolled `isActive` is a `startsWith`, so its highlight survives the redirect. |
**Music** (M-music) and **Soulseek** (M-slsk) are whole-workspace channel apps — pulled out below because each **Music** (M-music) and **Soulseek** (M-slsk) are whole-workspace channel apps — pulled out below because each
is **one design decision** that cascades across many files: is **one design decision** that cascades across many files:
@@ -122,7 +122,6 @@ This is the primary surface to convert to URL-driven selection.
| `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` | | `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` |
| `soulseek:section` | active Soulseek section | `/soulseek/:section` | `SoulseekNav/View/Dashboard/UserMenu` | | `soulseek:section` | active Soulseek section | `/soulseek/:section` | `SoulseekNav/View/Dashboard/UserMenu` |
| `soulseek:user` | selected peer | `/soulseek/users/:name` | `UserMenu.tsx:28`, `SoulseekUsers.tsx:34` | | `soulseek:user` | selected peer | `/soulseek/users/:name` | `UserMenu.tsx:28`, `SoulseekUsers.tsx:34` |
| `monitor:scope` | monitor scope | `/system-monitor/:scope` | `ScopeList.tsx` (via `shared.ts:15`) |
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` | | 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`, **Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`,
@@ -203,7 +202,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) ### Phase 3 — Whole-workspace routing decisions (needs a design call first)
- [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `<Link>` (M-music). - [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `<Link>` (M-music).
- [ ] **Soulseek** — decide the nested tree `/soulseek/:section` + `/soulseek/{users,search,rooms,chat}/:x`; **S1 decides S2S7** (M-slsk). - [ ] **Soulseek** — decide the nested tree `/soulseek/:section` + `/soulseek/{users,search,rooms,chat}/:x`; **S1 decides S2S7** (M-slsk).
- [ ] **M10** SystemMonitor scope → `/system-monitor/:scope` (`ScopeList.tsx:14`). - [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
- [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`). - [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`).
### Phase 4 — Polish + borderline decisions ### Phase 4 — Polish + borderline decisions
+4 -2
View File
@@ -644,8 +644,10 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
could never have worked: the link set the section, the tab stayed on Enterprise, and the content could never have worked: the link set the section, the tab stayed on Enterprise, and the content
pane rendered "Select a section" for a section that existed. The tab is derived from the section pane rendered "Select a section" for a section that existed. The tab is derived from the section
key now, so there is nothing left to disagree with. key now, so there is nothing left to disagree with.
Remaining here: Music, Soulseek, SystemMonitor, Email, Browser. **Chat detail **SystemMonitor is done too** (audit M10) — `/system-monitor/:scope`, scope list as `NavLink`s,
(`chat:selected-session`) is chat-owned — leave it; it is written up in `COMMS/`.** `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/`.**
### 5.9 The context has grown an app-config section — _(found 2026-08-07)_ ### 5.9 The context has grown an app-config section — _(found 2026-08-07)_
+1
View File
@@ -77,6 +77,7 @@ export function App() {
<Route path="/wallet" element={<Dashboard.WalletScreen />} /> <Route path="/wallet" element={<Dashboard.WalletScreen />} />
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} /> <Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} /> <Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} /> <Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} /> <Route path="/activity" element={<Dashboard.ActivityScreen />} />
@@ -1,14 +1,20 @@
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev'; import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev'; import { WorkspaceView, DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout'; import { defaultLayout } from './defaultLayout';
// /system-monitor uses the Workspace/Panel system (like /music): a horizontal split with an (empty for // /system-monitor uses the Workspace/Panel system (like /photos): the scope list on the left, the scope
// now) left panel and the system snapshot on the right. // itself on the right. Which scope is open is `:scope` in the URL, not a panel channel.
export const SystemMonitorScreen = () => { export const SystemMonitorScreen = () => {
const { scope } = useParams();
const workspace = useDashboardState<LayoutNode>('screens/system-monitor', defaultLayout); const workspace = useDashboardState<LayoutNode>('screens/system-monitor', defaultLayout);
// After the hooks — it returns early. Bare /system-monitor and a scope that doesn't exist both
// canonicalise, so the list's router-driven highlight can never disagree with the pane.
if (!isMonitorScope(scope)) return <Navigate to={monitorScopePath(DEFAULT_MONITOR_SCOPE)} replace />;
return ( return (
<div className="h-full w-full pt-2"> <div className="h-full w-full pt-2">
<WorkspaceView <WorkspaceView
@@ -3,9 +3,10 @@ import { BtopView } from './BtopView';
import { Pm2View } from './Pm2View'; import { Pm2View } from './Pm2View';
import { DockerView } from './DockerView'; import { DockerView } from './DockerView';
// Right panel — renders whichever scope the left ScopeList selected (default: bTop). // Right panel — renders whichever scope the URL names. It does not hear from the ScopeList; both read
// `/system-monitor/:scope`.
export const MonitorMain = ({ panelId: _panelId }: { panelId: string }) => { export const MonitorMain = ({ panelId: _panelId }: { panelId: string }) => {
const [scope] = useMonitorScope(); const scope = useMonitorScope();
if (scope === 'pm2') return <Pm2View />; if (scope === 'pm2') return <Pm2View />;
if (scope === 'docker') return <DockerView />; if (scope === 'docker') return <DockerView />;
return <BtopView />; return <BtopView />;
@@ -1,29 +1,29 @@
import { NavLink } from 'react-router';
import { Cpu, Server, Boxes } from 'lucide-react'; import { Cpu, Server, Boxes } from 'lucide-react';
import { useMonitorScope, SCOPES, type MonitorScope } from './shared'; import { SCOPES, monitorScopePath, type MonitorScope } from './shared';
// Left panel — a single-column list of scopes; selecting one drives the right panel via the channel. // Left panel — a single-column list of scopes. Each is a react-router NavLink to /system-monitor/<scope>,
// so active state comes from the router and a scope can be linked, bookmarked and cmd-clicked.
const ICONS: Record<MonitorScope, typeof Cpu> = { btop: Cpu, pm2: Server, docker: Boxes }; const ICONS: Record<MonitorScope, typeof Cpu> = { btop: Cpu, pm2: Server, docker: Boxes };
export const ScopeList = ({ panelId: _panelId }: { panelId: string }) => { export const ScopeList = ({ panelId: _panelId }: { panelId: string }) => (
const [scope, setScope] = useMonitorScope(); <div className="flex h-full flex-col overflow-y-auto p-3">
return ( {SCOPES.map((s) => {
<div className="flex h-full flex-col overflow-y-auto p-3"> const Icon = ICONS[s.id];
{SCOPES.map((s) => { return (
const Icon = ICONS[s.id]; <NavLink
return ( key={s.id}
<button to={monitorScopePath(s.id)}
key={s.id} className={({ isActive }) =>
type="button" `flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${
onClick={() => setScope(s.id)} isActive ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${ }`
scope === s.id ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground' }
}`} >
> <Icon size={15} className="shrink-0" />
<Icon size={15} className="shrink-0" /> <span className="truncate">{s.label}</span>
<span className="truncate">{s.label}</span> </NavLink>
</button> );
); })}
})} </div>
</div> );
);
};
@@ -1,18 +1,30 @@
import { usePanelChannel } from 'hooks/usePanelChannel'; import { useParams } from 'react-router';
// The left panel picks a scope; the right panel renders it. Coordinated over a panel channel, like the // The left panel picks a scope and the right panel renders it, and neither tells the other: the scope is
// /music browser → detail split. // `/system-monitor/:scope`. It used to be a `monitor:scope` panel channel, which meant the URL said
// `/system-monitor` however deep you were — nothing to link, nothing to bookmark, and the list came back
// on whatever you last clicked in some other tab.
export type MonitorScope = 'btop' | 'pm2' | 'docker'; export type MonitorScope = 'btop' | 'pm2' | 'docker';
export const MONITOR_SCOPE_CHANNEL = 'monitor:scope';
export const SCOPES: { id: MonitorScope; label: string }[] = [ export const SCOPES: { id: MonitorScope; label: string }[] = [
{ id: 'btop', label: 'bTop' }, { id: 'btop', label: 'bTop' },
{ id: 'pm2', label: 'pm2 processes' }, { id: 'pm2', label: 'pm2 processes' },
{ id: 'docker', label: 'dockers' }, { id: 'docker', label: 'dockers' },
]; ];
export const useMonitorScope = () => usePanelChannel<MonitorScope>(MONITOR_SCOPE_CHANNEL, 'btop'); /** Where /system-monitor lands, and where an unrecognised scope redirects to. */
export const DEFAULT_MONITOR_SCOPE: MonitorScope = 'btop';
export const isMonitorScope = (value: string | undefined): value is MonitorScope => SCOPES.some((s) => s.id === value);
export const monitorScopePath = (scope: MonitorScope) => `/system-monitor/${scope}`;
// SystemMonitorScreen redirects anything unrecognised, so the fallback here only covers the instant before
// that lands.
export const useMonitorScope = (): MonitorScope => {
const { scope } = useParams();
return isMonitorScope(scope) ? scope : DEFAULT_MONITOR_SCOPE;
};
export function fmtBytes(n: number): string { export function fmtBytes(n: number): string {
if (!n) return '0 B'; if (!n) return '0 B';
+4
View File
@@ -43,6 +43,10 @@ export type { PhotosSectionId } from './apps/Photos/shared';
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared'; export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
export type { JellyfinSectionId } from './apps/Jellyfin/shared'; export type { JellyfinSectionId } from './apps/Jellyfin/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';
// Same for /transmission. // Same for /transmission.
export { export {
DEFAULT_TRANSMISSION_SECTION, DEFAULT_TRANSMISSION_SECTION,