make the editable tab name survive refreshes

sessionStorage is the per-tab store — separate per tab, survives reload and
navigation, dies with the tab. The route title is derived rather than assigned,
so navigating no longer wipes a name you typed.

Duplicating a tab clones sessionStorage, so a `navigate` that arrives already
holding a name is treated as a clone and drops it; fails soft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:59:29 +00:00
co-authored by Claude Opus 5
parent dbd471c32d
commit 7700e8b540
3 changed files with 151 additions and 24 deletions
+47
View File
@@ -397,6 +397,53 @@ server code, not clicked through.
---
## 16. The tab you named stays named
`src/apps/officer-web/state/usePageTitle.ts`, `…/Layout/Header/Header.tsx`
The title in the middle of the header has been editable for a while, and it renames the browser tab as
you type — which is genuinely useful once you have six Officer tabs open and every one of them says
"Chat". It just didn't last. Two separate reasons: the name lived only in React Query, so a refresh
took it with the page; and `usePageTitleSync` had an effect that reset the title to the route default on
every navigation, so clicking anything at all wiped it even without a refresh.
**There is no tab id in the browser.** `chrome.tabs` gives an extension one, but page scripts are
deliberately not allowed to know which tab they are in, or that other tabs exist — it's the same
boundary that stops a page enumerating your windows. So there is no id to key the name on.
There doesn't need to be: **`sessionStorage` *is* the per-tab store.** It's separate per tab, it
survives a refresh and in-place navigation, and it's discarded when the tab closes. That is exactly the
lifetime a tab name wants. (`localStorage` would be wrong in the obvious way — every tab would share
one name, which is the problem, not the fix.)
So the name is read from `sessionStorage` at module load and mirrored into a `useGlobal` entry; the
React Query copy is what re-renders the header, and the storage copy is what survives the reload. The
route title is no longer *assigned* to the state, it's **derived**`label ?? titleForPath(pathname)`.
Navigation therefore retitles the tab by itself when you haven't named it, and leaves it alone when you
have. Clearing the field is the one way back to the route name, and there's no third state to get stuck
in.
**The one hole is duplicate-tab**, which you use constantly — and duplicating a tab clones its
sessionStorage, so the copy would open wearing the original's name. Two tabs called "Platform Arch" is
precisely what naming one was meant to prevent. `performance.getEntriesByType('navigation')[0].type`
separates the cases: a refresh reports `reload`, a duplicate reports `navigate`. So a `navigate` that
arrives already holding a name did not earn it, and the name is dropped. A brand-new tab is `navigate`
too but has nothing stored, so it's untouched.
That check **fails soft on purpose**: if the timing entry is missing, or some browser labels duplication
differently, the copy just keeps the name — redundant, not wrong. Nothing else about the tab changes.
The header input also became a draft committed on blur/Enter rather than a live write. It wrote every
keystroke before, which was fine while the title was a plain string; now that an empty field means "use
the route name", deleting the last character would have snapped the input to "Chat" under the cursor.
Escape discards the draft.
**Not verified:** the duplicate-tab navigation type, in an actual browser. The rest — that the name
survives a refresh and a navigation — follows from sessionStorage's specified behaviour, but the clone
heuristic is the part I could only reason about.
---
## Things noticed and deliberately left alone
- **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment
@@ -11,29 +11,39 @@ import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle';
// import { BugReportButton } from '../BugReport/BugReportButton';
// Center title: click to rename this browser tab (per-tab, not persisted).
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
// refresh and navigation and dies with the tab — see usePageTitle.
function EditablePageTitle() {
const [title, setTitle] = usePageTitle();
const [editing, setEditing] = useState(false);
const [title, rename] = usePageTitle();
const [draft, setDraft] = useState<string | null>(null);
if (editing) {
// The edit is a draft committed on blur/Enter rather than a live write. Writing every keystroke was
// fine while the title was a plain string, but an empty field now means "use the route name", so
// deleting the last character would snap the input to "Chat" underneath the cursor.
if (draft !== null) {
return (
<input
autoFocus
value={title}
onChange={(ev) => setTitle(ev.target.value)}
onBlur={() => setEditing(false)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === 'Escape') setEditing(false);
value={draft}
onChange={(ev) => setDraft(ev.target.value)}
onBlur={() => {
rename(draft);
setDraft(null);
}}
onKeyDown={(ev) => {
if (ev.key === 'Enter') ev.currentTarget.blur();
if (ev.key === 'Escape') setDraft(null); // discard, keeping whatever the tab was called
}}
aria-label="Tab name"
placeholder={title}
className="pointer-events-auto max-w-[50vw] border-b border-[#1d2724]/40 bg-transparent text-center text-base font-bold text-[#1d2724] outline-none md:text-xl"
/>
);
}
return (
<button
onClick={() => setEditing(true)}
title="Click to rename this tab"
onClick={() => setDraft(title)}
title="Click to rename this tab — clear it to go back to the page name"
className="pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold text-[#1d2724] transition-opacity hover:opacity-70 md:text-xl"
>
{title}
+83 -13
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { useLocation } from 'react-router';
import { useGlobal } from 'hooks/useGlobal';
@@ -45,22 +45,92 @@ export function titleForPath(pathname: string): string {
return RULES.find((r) => r.match(pathname))?.title ?? 'Officer';
}
/** The current page title — shared (header ↔ tab), editable, per-tab (not persisted). */
export const usePageTitle = () => useGlobal<string>('PAGE_TITLE', 'Officer');
// ── The name you gave this tab ──
// There is no tab identity in the DOM: `tabId` is a privileged extension API, and page scripts are
// deliberately not allowed to know which tab they are in or that other tabs exist. We don't need one —
// **sessionStorage is already the per-tab store.** It is separate per tab, survives a refresh and
// in-place navigation, and is discarded when the tab closes, which is exactly the lifetime of a tab
// name. localStorage would be wrong in the obvious way: every tab would share one name.
const TAB_LABEL_KEY = 'OFFICER_TAB_LABEL';
/** Storage can throw outright (private mode, storage disabled). A tab name is not worth a crash. */
function readTabLabel(): string | null {
try {
return sessionStorage.getItem(TAB_LABEL_KEY);
} catch {
return null;
}
}
function writeTabLabel(label: string | null): void {
try {
if (label) sessionStorage.setItem(TAB_LABEL_KEY, label);
else sessionStorage.removeItem(TAB_LABEL_KEY);
} catch {
/* the name just doesn't persist */
}
}
/**
* Mount once in the dashboard layout: reset the title to the route default on navigation, and mirror
* whatever the title is into the browser tab. Editing the title in the header just sets this value,
* so the tab updates live; navigating away resets it (no persistence, by design).
* Duplicating a tab **clones its sessionStorage**, so the copy would open wearing the original's name —
* two tabs called "Platform Arch", which is precisely what naming one was meant to prevent. Same for a
* tab opened by `window.open` from inside the app.
*
* A duplicate is a fresh navigation and a refresh is not, so `navigation.type` separates them: only a
* `navigate` arriving with a name it did not earn is a clone. A brand-new tab is `navigate` too, but has
* nothing stored, so it is untouched.
*
* It fails soft on purpose. If the timing entry is missing, or a browser labels duplication some other
* way, the copy simply keeps the name — redundant, not wrong.
*/
function dropInheritedTabLabel(): void {
try {
const [nav] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
if (nav?.type === 'navigate' && readTabLabel()) writeTabLabel(null);
} catch {
/* leave it alone */
}
}
// Once per document, before React reads the stored name.
dropInheritedTabLabel();
/**
* The title shown in the header and in the browser tab, plus the setter behind the header's edit.
*
* A name you type is a property of the **tab**, not of the page, so it outlives navigation as well as a
* refresh: you named this window to find it again among a dozen others, and wiping it because you opened
* a different screen would defeat the point. Clear the field to hand the tab back to the route's own
* name — that is the only way out, and there is no third state to get stuck in.
*/
export function usePageTitle() {
const { pathname } = useLocation();
const [label, setLabel] = useGlobal<string | null>('TAB_LABEL', readTabLabel);
// The React Query copy is what re-renders every consumer; sessionStorage is what survives the reload.
const rename = useCallback(
(next: string) => {
const trimmed = next.trim() || null;
writeTabLabel(trimmed);
setLabel(trimmed);
},
[setLabel],
);
return [label ?? titleForPath(pathname), rename] as const;
}
/**
* Mount once in the dashboard layout: mirror the title into the browser tab.
*
* There used to be a second effect resetting the title to the route default on every navigation, which
* is what made a typed name last only until you clicked something. The route default is derived now
* rather than assigned, so navigation retitles the tab by itself when no name is set, and leaves it
* alone when one is.
*/
export function usePageTitleSync(): void {
const { pathname } = useLocation();
const [title, setTitle] = usePageTitle();
// Route default — depends only on the path, so an in-place header edit is never clobbered.
useEffect(() => {
setTitle(titleForPath(pathname));
}, [pathname]);
const [title] = usePageTitle();
useEffect(() => {
document.title = title || 'Officer';