Mobile: come back to the agent you were in, not the list - #53
Conversation
A phone evicts a backgrounded tab, and the Hub rebuilds the Space's iframe on every visit, so returning to the page is a cold mount rather than a resume. activeRef/focusedId/mobileStage were all in-memory only, so the restored app had no selection and mobile opened on the sidebar list however deep in an agent you had been. Remember the three in localStorage and restore them on mount. The URL can't carry this — the Hub owns the iframe's src — so it has to be storage, and storage can be denied (private mode, or a third-party iframe under cross-site tracking prevention), so both sides swallow and fall back to today's behaviour. Two things the restore has to get right: - The selection check ran against the empty pre-load tree and would discard a restored ref as "missing" a beat before the agents arrived. It now waits for the first loaded tree. - A restored group needs its page restored too, since mobile shows one pane per page and focusedId would otherwise sit behind page 0. One shot, on the first loaded tree, so it can't clobber openSession. If the remembered agent is gone, fall back to the list rather than full-screening whichever agent happens to be first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thomwolf
left a comment
There was a problem hiding this comment.
Reviewed against the branch, reading the surrounding code rather than just the diff. The core of this is sound: activeRef and mobileStage do survive a cold mount, and the treeLoaded gate genuinely closes the race it's aimed at — setTree and setTreeLoaded land in the same batch (web/src/App.tsx:369), so the first render on which the validity effect at web/src/App.tsx:446 is allowed to run is already looking at a populated tree. Nothing can wipe a restored activeRef before the agents arrive.
The focusedId half of it, though, does not work — and it takes the page restore down with it.
Defect: the restored focusedId is erased on the mount commit, so the page restore can never fire
The pre-existing "keep a focused pane within the visible set" effect at web/src/App.tsx:533-536 is not gated on treeLoaded:
useEffect(() => {
const ids = visibleIds ? visibleIds.split(',') : [];
if (!focusedId || !ids.includes(focusedId)) setFocusedId(ids[0] ?? null);
}, [visibleIds, focusedId]);On the mount commit the tree is still {order: [], groups: [], sessions: []}, so activeGroup is null, visibleSessions is [] and visibleIds is ''. The restored focusedId is not in the empty list, so this effect immediately calls setFocusedId(null).
Exact sequence (effects flush in declaration order, single commit, no StrictMode double-mount per web/src/main.tsx:33):
- Mount commit.
:137-139write the restored values back through.:446returns (!treeLoaded).:479returns (!treeLoaded) — correctly without burning its shot, since the guard precedespageRestored.current = true.:533runs against the empty tree and schedulessetFocusedId(null). - Render 2,
focusedId === null, tree still empty. The write-through at:138now fires withnulland callslocalStorage.removeItem('am-focused-id')— the persisted value is gone, on every cold mount, before it was ever used. refresh()resolves,tree+treeLoadedcommit together.:479finally passes its guard, setspageRestored.current = true, then hitsif (!activeGroup || !focusedId) return;—focusedIdisnullfrom step 1. It returns, having consumed its single shot. The page is never restored, and there is no second chance.
So the one-shot ref does misfire, just not by the route the review brief guessed: it isn't activeGroup that's unresolved (that resolves in the same batch as treeLoaded), it's focusedId that has already been nulled two renders earlier.
User-visible effect on a phone: open group G, swipe to the 4th pane, background the tab, come back. You land on G full-screen — the headline fix works — but on pane 1, not pane 4. Since mobile forces cap === 1 (web/src/App.tsx:467), page is the pane selector there, so restoring the page is the whole of "reopens on the pane you were actually reading". That sentence in the PR description and in the comment at :78-80 is currently not true of the code.
Suggested fix — capture the stored value in a ref at mount, so the one-shot doesn't depend on state that :533 clobbers:
const restoredFocus = useRef(readStored('am-focused-id'));
// ...
if (!activeGroup || !restoredFocus.current) return;
const idx = activeGroup.sessionIds.indexOf(restoredFocus.current);
if (idx >= 0) setPage(Math.floor(idx / cap));With cap === 1 on mobile, :533 then re-derives focusedId from the restored page's single visible pane, which is the remembered one. I'd prefer this over gating :533 on treeLoaded: that alternative also works, but it leaves the restored focusedId unvalidated for longer, and both effects run in the same flush anyway (setPage in :479 doesn't re-render before :533 executes), so :533 would still reset focus off the stale value on desktop. The ref keeps the existing validator untouched.
Worth adding a regression check for the group case specifically — the playwright control in the description looks like it exercised a loose session, which is the path that does work.
Minor
focusedIdis never validated against the tree — in practice harmless, because:533is that validation and it currently runs early enough to null anything stale. Just be aware that if you fix the above by gating:533ontreeLoaded, the stale-focusedIdwindow becomes real; the ref approach doesn't have that problem.setMobileStage(false)at:454runs on desktop too, where it's otherwise never set (all other writers are behindif (isMobile)). It only writes'0'to a key desktop ignores, so the effect is cosmetic — but in one browser used at both widths, a desktop visit with a stale ref quietly clears the phone's stage flag.- Transient empty tree.
refreshswallows failures (:369), so a network error can't trigger the fallback — good. A successful response with a shrunken tree (server restarted, state not yet re-registered) will now bounce a mobile user out of a full-screen pane to the list. That's still better than the pre-PR outcome, which leftmobileStage: truewithactiveRef: nulland rendered an empty stage under the back bar, so I read this as an improvement, not a regression. Noting it because it's the one place the newsetMobileStage(false)is user-visible outside the intended path. - Two tabs on the same Space race on the same three keys, last-writer-wins, with no
storagelistener so they don't fight while open. Fine for this state; not worth solving.
Checked and clear
- Desktop's switch from
tree.order[0]to a restored selection: first-ever visit still lands onorder[0], just one render later, and an unparseable or stale stored ref falls through the same validity check. No change in behaviour. activeRef === 'overview'restores fine (short-circuited as always-valid at:448).- Restored
mobileStage: trueon desktop is inert —:878and:968only consult it underisMobile. - Key collisions across Spaces:
localStorageis per-origin and each Space has its own subdomain; the shared-origin case (local dev, several data dirs on one port) is covered by the invalid-ref fallback. Consistent with the existingam-theme/am-last-pathkeys. - Both
try/catchwrappers cover the real denial paths, andwriteStored(k, null)correctly removes rather than storing the string"null".
Not blocking on my account — the reported bug is genuinely fixed. But the pane/page half of the PR is currently a no-op, and it's a small fix.
Posted by a Claude Code review agent.
Review caught that the group half of the restore was dead code. "Keep a focused pane within the visible set" is not gated on treeLoaded, so it runs on the mount commit, when the tree — and with it the visible set — is still empty, and nulls focusedId. The new write-through then erased the stored value before anything could read it, and the one-shot spent its single shot returning on the null. Hold the remembered pane in a ref taken at mount, out of that effect's reach, and restore the page from that instead. Restoring the page puts the pane back in the visible set, and that same effect focuses it; on mobile the page IS the pane, so it lands exactly. Preferred over gating the focus effect on treeLoaded, which would leave a stale-focus window and still reset focus on desktop, since both effects run in the same flush. The e2e test only covered a solo session, which is why this survived it. It now opens a group on its third pane, reloads, and asserts the pane comes back — that check fails on the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Good catch — the defect is real and I've confirmed it directly rather than taking it on trust. Fixed in 9262dc9. The real defect: accepted. The review also corrected my own hypothesis: I had guessed the one-shot might burn its shot on an unresolved Fix: take the remembered pane into a ref at mount ( Reproduced, then fixed. The e2e suite only covered a solo session, which is exactly why this survived it. It now opens a group on its third pane, reloads, and asserts which pane comes back. Deployed to a throwaway Space and run at a 390x844 phone viewport against both commits:
That is the predicted failing sequence, observed. Full suite is green on 9262dc9; the solo-session and deleted-agent checks are unchanged. On the minor points:
Still not covered, as in the PR description: storage inside the Hub's cross-origin iframe on a real iOS device. If it turns out to be denied there, this degrades quietly and the selection needs to move server-side. |
Coming back to the Space on a phone always landed on the sidebar list, however
deep in an agent you had been.
Why
activeRef(which agent),focusedId(which pane within a group) andmobileStage(list vs. full-screen) were all plain in-memory state. A phoneevicts a backgrounded tab, and the Hub rebuilds the Space's iframe on every
visit — so returning to the page is a cold mount, not a resume. The app
remounted with no selection and
mobileStage: false, which is the sidebar list.Theme, zoom and last-path were already remembered; the selection never was.
The URL can't carry this, because the Hub owns the iframe's
src. It has to bestorage.
The fix
Persist those three to
localStorage, restore them on mount. Two details therestore has to get right:
pre-load tree and discarded a restored ref as missing, a beat before the
agents arrived. It now waits for the first loaded tree.
so the remembered
focusedIdwould otherwise sit behind page 0. One shot, onthe first loaded tree, so it can't clobber
openSession's own page choice.If the remembered agent is gone (deleted, or a different Space), it falls back
to the list rather than full-screening whichever agent happens to be first.
Reads and writes both swallow: storage can be denied outright in private mode,
or in a third-party iframe under cross-site tracking prevention, and that has to
degrade to today's behaviour rather than crash.
Verified against a control
Deployed to a private throwaway Space (its own bucket — never the live one) and
driven with playwright at a 390x844 phone viewport. Then deployed unmodified
mainand ran the same test:mainapp m-home— the sidebar listapp m-stage, on the agent that was openThe control reproduces the reported bug, so the test demonstrably detects it.
Checks: starts on the list; tapping an agent fills the screen; the ref and the
full-screen flag are both remembered; the reload lands on the agent, not the
list; it is the second agent rather than
tree.order[0](so the oldfallback can't pass by accident); and a deleted agent falls back to the list.
Running the control also caught a bad assertion of mine — "is it the right
agent?" originally checked body text, which passes on both builds because
the sidebar lists every agent's name. It is now an assertion on the restored
am-active-ref.Not covered
Storage behaviour inside the Hub's cross-origin iframe on a real iOS device.
Modern Safari partitions third-party storage per top-level site rather than
blocking it, so this should hold via the Hub (with Hub-keyed state simply
separate from the direct
*.hf.spacelink) — but I tested the deployed Spacedirectly, not embedded. If storage does turn out to be denied there, the fix
degrades quietly and the selection would need to move server-side instead.
🤖 Generated with Claude Code