From 7700e8b54022dd6b5a728884c6232c1041fc3623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 00:59:29 +0000 Subject: [PATCH] make the editable tab name survive refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/chat-ui-walkthrough.md | 47 +++++++++ .../Dashboard/Layout/Header/Header.tsx | 32 ++++--- src/apps/officer-web/state/usePageTitle.ts | 96 ++++++++++++++++--- 3 files changed, 151 insertions(+), 24 deletions(-) diff --git a/docs/chat-ui-walkthrough.md b/docs/chat-ui-walkthrough.md index 5ffcf4cb..8e8d9589 100644 --- a/docs/chat-ui-walkthrough.md +++ b/docs/chat-ui-walkthrough.md @@ -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 diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx index c9d40976..5b0394ec 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx @@ -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(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 ( 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 (