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
@@ -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';