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