page title: derive from the route path instead of a hand-maintained table

RULES was a hardcoded map from every route prefix to a title, which meant a
new route silently fell through to 'Officer' until somebody remembered to
add an entry — the gap that hit installed-plugin routes, which had no entry
at all. titleForPath now takes the URL's first path segment and title-cases
it, so every route gets a sane tab title for free, including ones a plugin
adds at runtime. Trade-off: a few routes lose their hand-tuned casing
(QR Transfer -> Qr Transfer, Command HQ -> Command Hq) and settings
sub-routes collapse to the one 'Settings' title instead of a distinct one
per section — both accepted as the point of removing the table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:03:33 +00:00
co-authored by Claude Opus 5
parent 6235662492
commit ab636eaa42
2 changed files with 19 additions and 37 deletions
+5 -3
View File
@@ -349,9 +349,11 @@ Worked examples: `/soulseek`, `/music`, `/chat`.
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
- **Page title by route.** Add a rule to `RULES` in `src/apps/officer-web/state/usePageTitle.ts`
(`{ match: (p) => p.startsWith('/<name>'), title: '<Name>' }`, most-specific first);
`usePageTitleSync` does the rest. `startsWith` means nested routes are covered.
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
`usePublishPageTitle` in `officerdev`.
### The URL is the source of truth for selection
+14 -34
View File
@@ -5,41 +5,21 @@ import { usePageTitleOverride } from 'officerdev';
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
import { randomId } from 'helpers/random-id';
type TitleRule = { match: (p: string) => boolean; title: string };
// Default page title per route — most specific first.
const RULES: TitleRule[] = [
{ match: (p) => p === '/', title: 'Home' },
{ match: (p) => p.startsWith('/settings/ai'), title: 'AI Settings' },
{ match: (p) => p.startsWith('/settings/profile'), title: 'Profile' },
{ match: (p) => p.startsWith('/settings/integrations'), title: 'Integrations' },
{ match: (p) => p.startsWith('/settings/user-management'), title: 'User Management' },
{ match: (p) => p.startsWith('/settings/system'), title: 'System' },
{ match: (p) => p.startsWith('/settings'), title: 'Settings' },
{ match: (p) => p.startsWith('/chat'), title: 'Chat' },
{ match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
{ match: (p) => p.startsWith('/tasks'), title: 'Tasks' },
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
{ match: (p) => p.startsWith('/skills'), title: 'Skills' },
{ match: (p) => p.startsWith('/processes'), title: 'Processes' },
{ match: (p) => p.startsWith('/dashboards'), title: 'Dashboards' },
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
{ match: (p) => p.startsWith('/browser'), title: 'Browser' },
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
];
// Default page title per route — derived from the path itself rather than a hand-maintained table, so
// a new route (including one an installed plugin brings in at runtime) gets a sane tab title for free
// instead of silently falling through to 'Officer' until somebody remembers to add a rule.
//
// Only the first segment counts — `/settings/ai` and `/settings/profile` both say 'Settings' — because
// that segment is the one every route conventionally owns (see "Frontend route conventions" in the root
// CLAUDE.md). A screen that wants to say more than its route does publishes an override instead; see
// `usePageTitleOverride` below.
export function titleForPath(pathname: string): string {
return RULES.find((r) => r.match(pathname))?.title ?? 'Officer';
const [first] = pathname.split('/').filter(Boolean);
if (!first) return 'Home';
return first
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
// ── The name you gave this tab ──