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:
@@ -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
|
## Things noticed and deliberately left alone
|
||||||
|
|
||||||
- **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment
|
- **`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 { usePageTitle } from '@/state/usePageTitle';
|
||||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
// 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() {
|
function EditablePageTitle() {
|
||||||
const [title, setTitle] = usePageTitle();
|
const [title, rename] = usePageTitle();
|
||||||
const [editing, setEditing] = useState(false);
|
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 (
|
return (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={title}
|
value={draft}
|
||||||
onChange={(ev) => setTitle(ev.target.value)}
|
onChange={(ev) => setDraft(ev.target.value)}
|
||||||
onBlur={() => setEditing(false)}
|
onBlur={() => {
|
||||||
onKeyDown={(ev) => {
|
rename(draft);
|
||||||
if (ev.key === 'Enter' || ev.key === 'Escape') setEditing(false);
|
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"
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setDraft(title)}
|
||||||
title="Click to rename this tab"
|
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"
|
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}
|
{title}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
|
||||||
@@ -45,22 +45,92 @@ export function titleForPath(pathname: string): string {
|
|||||||
return RULES.find((r) => r.match(pathname))?.title ?? 'Officer';
|
return RULES.find((r) => r.match(pathname))?.title ?? 'Officer';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The current page title — shared (header ↔ tab), editable, per-tab (not persisted). */
|
// ── The name you gave this tab ──
|
||||||
export const usePageTitle = () => useGlobal<string>('PAGE_TITLE', 'Officer');
|
// 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
|
* Duplicating a tab **clones its sessionStorage**, so the copy would open wearing the original's name —
|
||||||
* whatever the title is into the browser tab. Editing the title in the header just sets this value,
|
* two tabs called "Platform Arch", which is precisely what naming one was meant to prevent. Same for a
|
||||||
* so the tab updates live; navigating away resets it (no persistence, by design).
|
* 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 {
|
export function usePageTitleSync(): void {
|
||||||
const { pathname } = useLocation();
|
const [title] = usePageTitle();
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = title || 'Officer';
|
document.title = title || 'Officer';
|
||||||
|
|||||||
Reference in New Issue
Block a user