diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index 4fc4c917..41a98b15 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -43,6 +43,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx
index a1ec0821..d206d440 100644
--- a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx
@@ -1,13 +1,18 @@
import { useEffect, useMemo } from 'react';
+import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
-import { WorkspaceView } from 'officerdev';
+import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /headscale uses the Workspace/Panel system (like /soulseek and /music): a section nav (headscale-nav) on
-// the left and a section view (headscale-view) on the right, coordinating via the 'headscale:section'
-// channel. Both talk to the officer-headscale sidecar through the /api/headscale auth proxy, which holds no
-// Headscale credentials of its own — the registered servers and their keys live in the sidecar.
+// the left and a section view (headscale-view) on the right. Both talk to the officer-headscale sidecar
+// through the /api/headscale auth proxy, which holds no Headscale credentials of its own — the registered
+// servers and their keys live in the sidecar.
+//
+// The open section is :section in the URL, so both panels read it with useParams instead of passing it
+// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the
+// single place that decides what an absent or bogus section means.
const ALLOWED_APP_TYPES = new Set(['headscale-nav', 'headscale-view', null]);
@@ -24,6 +29,7 @@ function normalizeLayout(node: LayoutNode): LayoutNode {
}
export const HeadscaleScreen = () => {
+ const { section } = useParams();
const rawWorkspace = useDashboardState('screens/headscale', defaultLayout);
const workspace = useMemo(() => {
@@ -38,6 +44,13 @@ export const HeadscaleScreen = () => {
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
+ // Bare /headscale, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
+ // default while the address bar says something else — the nav highlight is derived from the URL, so a URL
+ // that names nothing would leave nothing highlighted.
+ if (!isHeadscaleSection(section)) {
+ return ;
+ }
+
return (
diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx
index e613b675..871d5f7b 100644
--- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx
+++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx
@@ -1,14 +1,17 @@
import type { LucideIcon } from 'lucide-react';
+import { NavLink } from 'react-router';
import { Network, Server, Laptop, Users, KeyRound, Check } from 'lucide-react';
-import { usePanelChannel } from 'hooks/usePanelChannel';
-import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
+import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
-// Left panel of the /headscale workspace: the active-server switcher on top, sections below. Publishes the
-// selected section on 'headscale:section'; HeadscaleView (right) renders the matching UI.
+// Left panel of the /headscale workspace: the active-server switcher on top, sections below.
//
-// Switching servers is the primary action here rather than a buried setting — the owner runs several
-// control servers and every other section is scoped to whichever is active.
+// Sections are real links to /headscale/, not channel writes — so they cmd-click into a new tab,
+// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
+// being derived in JS, per the navigation audit's Phase 4.
+//
+// The server switcher stays a button on purpose: activating a server is a mutation (a DB write that changes
+// which server every other section acts on), not navigation. It has no URL of its own and shouldn't.
const ICONS: Record = {
servers: Server,
@@ -17,8 +20,21 @@ const ICONS: Record = {
keys: KeyRound,
};
+const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
+
+type SectionBodyProps = { icon: LucideIcon; label: string; selected: boolean };
+
+const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => (
+ <>
+ {selected && }
+
+ {label}
+ >
+);
+
export const HeadscaleNav = () => {
- const [section, setSection] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers');
const { servers, active, activate } = useHeadscaleServers();
return (
@@ -63,30 +79,30 @@ export const HeadscaleNav = () => {
diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx
index bf3b4c4d..e7daf29f 100644
--- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx
+++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx
@@ -1,17 +1,16 @@
-import { usePanelChannel } from 'hooks/usePanelChannel';
-import { HEADSCALE_SECTION_CHANNEL, type HeadscaleSectionId } from './shared';
+import { useHeadscaleSection } from './useHeadscaleSection';
import { ServersView } from './ServersView';
import { NodesView } from './NodesView';
import { UsersView } from './UsersView';
import { KeysView } from './KeysView';
-// Right panel of the /headscale workspace — renders the section the nav selected.
+// Right panel of the /headscale workspace — renders the section named by the URL.
//
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
// itself through ViewShell, so there is no gating to do here.
export const HeadscaleView = () => {
- const [section] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers');
+ const section = useHeadscaleSection();
switch (section) {
case 'nodes':
diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx
index ac57c2a2..0a03c196 100644
--- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx
+++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx
@@ -1,13 +1,13 @@
import { Network } from 'lucide-react';
import { useHeadscaleServers } from './useHeadscaleServers';
-import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
-import { usePanelChannel } from 'hooks/usePanelChannel';
+import { HEADSCALE_SECTIONS } from './shared';
+import { useHeadscaleSection } from './useHeadscaleSection';
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
export const HeadscaleViewHeader = () => {
- const [section] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers');
+ const section = useHeadscaleSection();
const { active } = useHeadscaleServers();
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
diff --git a/src/workspaces/officerdev/src/apps/Headscale/shared.ts b/src/workspaces/officerdev/src/apps/Headscale/shared.ts
index 2e138d5d..acfd69e2 100644
--- a/src/workspaces/officerdev/src/apps/Headscale/shared.ts
+++ b/src/workspaces/officerdev/src/apps/Headscale/shared.ts
@@ -4,9 +4,6 @@
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
// version. See src/servers/sidecar/headscale/routes.ts.
-/** Selected section, published by HeadscaleNav and consumed by HeadscaleView. */
-export const HEADSCALE_SECTION_CHANNEL = 'headscale:section';
-
export const HEADSCALE_SECTIONS = [
{ id: 'servers', label: 'Servers' },
{ id: 'nodes', label: 'Nodes' },
@@ -16,6 +13,15 @@ export const HEADSCALE_SECTIONS = [
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
+/** Where /headscale lands, and where an unrecognised section redirects to. */
+export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';
+
+export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
+ HEADSCALE_SECTIONS.some((s) => s.id === value);
+
+/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
+export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${id}`;
+
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
export type HeadscaleServer = {
id: number;
diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleSection.ts b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleSection.ts
new file mode 100644
index 00000000..3bb65cad
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleSection.ts
@@ -0,0 +1,12 @@
+import { useParams } from 'react-router';
+import { DEFAULT_HEADSCALE_SECTION, isHeadscaleSection, type HeadscaleSectionId } from './shared';
+
+// The URL is the source of truth for which section is open — not a panel channel. See docs/navigation-audit.md:
+// selection held in a channel means the id lives only in an onClick closure, so the section can't be linked to,
+// opened in a new tab, or reached with the back button. HeadscaleScreen redirects anything unrecognised, so the
+// fallback here is only for the instant before that lands.
+
+export function useHeadscaleSection(): HeadscaleSectionId {
+ const { section } = useParams();
+ return isHeadscaleSection(section) ? section : DEFAULT_HEADSCALE_SECTION;
+}
diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts
index abfc8abb..f15eeaf6 100644
--- a/src/workspaces/officerdev/src/index.ts
+++ b/src/workspaces/officerdev/src/index.ts
@@ -27,6 +27,9 @@ export * from './apps/Chat/types';
export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory';
export type { SelectedSession } from './apps/ChatHistory';
export { CodeEditorView } from './apps/CodeEditor';
+// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL.
+export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';
+export type { HeadscaleSectionId } from './apps/Headscale/shared';
export {
useFilesAPI,
useTasks,