Repository navigation
Keep workspace chrome still when switching documents - #346
Conversation
|
Review: Changes needed The idea is right. In a single calm switch (1440×900, 390×844, reduced motion, create document), the header, tabs and Chat frame stay still. The body fades out over about 80ms, there is a blank frame of about 25ms, and the new body fades in over about 150ms. Typed edits are flushed to the outgoing document, and focus lands on the clicked sidebar row, as it does on main. But fast navigation can leave the workspace permanently blank. Blocker: rapid switching or back/forward leaves no visible documentTo reproduce, use the fake-GitHub server and click sidebar documents 60–120ms apart (JS After it settles (and still after +5s), the DOM is: The URL and the sidebar selection show the right document, but the main area is empty. Clicking another document does not recover it. Only a reload does. Cause: in the new policy, Suggested fix (either works, both is better):
Should fix
Notes
CI (
|
|
Thanks. Fixed in c03ab05:
|
|
Review: Looks good I re-checked c03ab05 adversarially in a fresh worktree on a fake-GitHub server. I used per-frame (rAF) monitoring of route layers, header title, sidebar selection and body opacity, then typed into the final document to confirm it is interactive. Each case was repeated 1–3 times. The stuck or blank state no longer reproduces: 47 of 47 runs pass. In every run the final state is a single
The new reducer tests, the motion-contract test and the Slow network. With 400ms of latency, the header and body now follow the last input after about 510ms: the fetch, plus the 80ms fade-out. Before the fix this was about 1030ms, and main takes about 455ms. The sidebar and URL still move within about 10ms, so there is an immediate cue. The gap between the header and the sidebar is now only the fetch time, as on main. Holding the old document for that long is therefore not a perceptible "nothing happened" delay. Non-blocking nits:
CI (
|
c03ab05 to
0e2ed01
Compare
Switching or creating a document cross-faded and slid the whole route layer, so the header, tabs and Chat frame were drawn twice mid-switch. The incoming route now loads unseen while the outgoing one stays on screen until its document syncs (at most 500ms). Then only the document body and transcript change, opacity only: out over 80ms, in over 150ms, never overlapping. The header and Chat frame swap in place. Sibling Document/Decisions tabs keep their directional slide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A route that became ready while another was still leaving turned the never-shown current route into the leaving one. Its layer had already counted its close as reported before it had a listener, so the swap never finished and the workspace stayed blank. The reducer now drops a never-shown route, and ContentSwapLayer only counts a close as reported once a listener has run. The sync hold is now capped at 200ms from the request, waits on the requested surface (the child for child routes), and the route timing lives in the motion contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0e2ed01 to
b9004ec
Compare
Why
Clicking a document in the sidebar, or creating one, cross-faded and slid the whole workspace, header, tabs and Chat frame included. Halfway through, both documents were drawn at about 50% opacity, 8px apart: two titles, two "Document" tabs. The Chat pane also went blank until the transcript arrived. It felt like a page reload, not like switching documents.
What changed
[data-workspace-document-swap]and[data-chat-stack]fade out over 80ms (--route-swap-out-dur), then fade in over 150ms (--route-swap-in-dur,--ease-out). The two fades never overlap, and nothing translates.visibility: hidden, inert) and is shown only once its document has synced (for a child route, the child's document), capped at 200ms from the click. On a slow network the header and body therefore never disagree with the sidebar for long. Locally the transcript arrives in the same frame, so the new document fades in complete. The whole switch is quick (about 300ms), so a skeleton would flash for a moment and add noise. A held page that then fades in feels more like Linear.ContentSwapLayergains an optionalstagedstate. Its close callback now runs before paint, so the incoming route replaces the outgoing one in the same frame. A close only counts as reported once a listener has actually run.route-swapentry inmotion-contract.ts.motion-content-swapis unchanged).No lines are shared with #297. It touches
plan-editor.tsxandtheme.cssin other places.Screenshots
Before: switching documents (main). Frames at 0, 50, 100, 150, 200, 260 and 330ms. The header, tabs and Chat are double-exposed and washed out.
After: switching documents. The chrome stays put while the body and transcript fade out, then the new ones fade in.
Before / after: creating a new document
After: reduced motion (instant, with no blank frame)
GIFs (about 2× slow motion):

Testing
bun run types: passbun test apps/web packages/editor: 964 pass. The 2focus.test.tsfailures were 5s timeouts on a loaded machine and pass with--timeout 60000.bun scripts/check-design-record.ts,bun run design:check, dprint, oxlint and token checks: passbun run build: passes within the initial JS budget now that Move signed-out and lazy-only code out of the initial bundle #354 has landed. This PR is rebased on main.e2e/document-navigation.e2e.tsande2e/hosted.e2e.tsare covered by CINeeds a design-contract review
bun scripts/check-design-contract.tsreports "reviewed dynamic owner changed" for these files. I did not edit the hashes:apps/web/src/document-workspace-host.tsxdynamic-web.jsonb4d855546f8b08a7152322f0b27a24667c7cf788e64ff40d2647643e875fe3a9packages/editor/src/content-swap.tsxdynamic-editor.json099ee516920e9e9f999b28cc1f8dd0b3b2a853697aa1b07429d0ffadb229cc64packages/editor/src/plan-editor.tsxdynamic-editor.json44ba2fd80e1b61071260ce90c98ea42b7ae4a46b04aac7239c5a41b612bc5e87These changes are safe. The class logic is unchanged. The host only defers its
onReadycall,content-swap.tsxadds adata-content-swap-state="staged"value and ahiddenguard, andplan-editor.tsxadds adata-plan-syncedattribute.🤖 Generated with Claude Code