fix(workspace): reserve the rail column while the stage loads (#2711) - #2785
Conversation
Review —
|
db37874 to
0524908
Compare
2d51d0a to
fc337ce
Compare
Re-review — [C1] and [C2] fixed
[C1] — the unfulfilled reservationI did not narrow the predicate, and the reason is worth stating: reserving only on Instead the leave is animated only when the column actually held a rail (
The enter keeps its transition unconditionally — it only ever runs for the voice-canvas swap (#2676), since a reserved column is present from the first frame and never enters. [C2] — the spec now measures the route the AC namesTwo arms added:
Negative control on the new arm: forcing the old unconditional leave fails it with One spec updated rather than deleted
Residual, stated rather than hidden
Frontend unit suite 135 files / 2957 tests; rail + model-choice e2e 12/12. |
Follow-up — a real bug in my own fix, found by chasing the CI red
It is not a silent no-op, it is a silent throw. Vue routes a watch-getter error to its error handler rather than aborting setup, so:
I only found it because #2787's e2e went red after the rebase and the first plausible story — an 11.4-minute run versus the previous 2.5 — was "slow runner, ignore it". Reproducing locally instead, then reading the browser console rather than the test output, is what surfaced it. What was and wasn't affected. The measured behaviour in the previous comment stands, because Guarded, not just fixed. Unit suite 135 files / 2959 tests; the three workspace suites CI flagged — rail-reserved, model-choice, stick-to-bottom — 15/15 locally. #2787 rebased onto this; its own diff is unchanged at 95 files / 752 / 330. |
0524908 to
32600a5
Compare
810e9f7 to
e5d44ad
Compare
/review — post-rebase (stacked on
|
32600a5 to
32415e0
Compare
e5d44ad to
1628303
Compare
32415e0 to
c5d2737
Compare
1628303 to
2925833
Compare
c5d2737 to
b2a5fc9
Compare
2925833 to
85111e8
Compare
|
merge-train: pushed the |
b2a5fc9 to
f7bccc1
Compare
On every cold load the conversation column rendered full width, then lost the rail's width the moment the roster arrived — sliding the composer, the thread and the header left. Since #2676 that is a 300ms animated slide rather than a one-frame jump, which is prettier and still a shift: the contract's layout-stability rule is that loading and loaded share ONE footprint and nothing moves on arrival. Measured on a local instance before the fix, the Send button at 1440px goes 1327 → 1300 (27px) as the roster lands, and 916 → 884 (32px) at 1024px. `railVisibleFor` answers "may the rail RENDER", and false for a non-ready stage is right — its tabs need the roster. But the column's WIDTH does not need the roster: it comes from the persisted rail state and is known synchronously at first paint. So the wrapper now renders on `railHasColumn || railColumnReserved` and gates only `PortalRail` inside. Space is reserved; content is not faked. `railColumnReservedFor` is deliberately NARROWER than `railVisibleFor`'s route set, and the exclusions are the interesting part: * an agent page never carries a rail, so reserving there would invent a gap; * a ROOM route is excluded even though a ready room usually has a rail, because that depends on `roomsAvailable`, which arrives ON the roster payload (#2128). Reserving against a capability we have not been told about yet would trade this shift for the opposite one on every install without rooms. So it reserves for exactly the case the bug is about: a 1:1 conversation route, mid-load. If the rail then turns out not to render, the column leaves through the existing width transition — a shrink, not a jump. Nothing about the motion changes: the `<Transition>` classes, the #2676 voice-canvas swap and `motion-reduce` are untouched, and the reserved column is present from the first frame so its enter never runs. Verified: the composer's x is stable within 1px across 24 samples through roster arrival at 1440/1024/768/640, the column keeps the same width when the rail lands, and `workspace-model-choice.spec.js` — the spec whose measurements the #2676 transition split — stays green. Reverted, the same spec fails with the 27px/32px slides above, so it bites. Unit suite 135 files / 2957 tests. One e2e case was written and then removed rather than left skipping: "an agent page carries no rail column" asserted a premise the router does not hold — `/workspace/a/:name` REDIRECTS into a conversation, which legitimately has a rail. The rule it meant to check is covered in the unit spec, which does not depend on which URL the router settles on. Fixes #2711 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
#2711) Review finding, reproduced: the reservation fires for any non-room, non-agent- page route while the stage loads, but `stageZone` keys on the ROSTER — so a caller with no rostered agents settles `empty` and a failed roster fetch settles `failed`. In both the rail never arrives, the reservation drops, and the column left through the 300ms width transition: EMPTY roster -> none -> reserved(48) -> reserved(22) -> reserved(3) -> none FAILED roster -> none -> reserved(48) -> reserved(10) -> reserved(1) -> none A 48px animated shift on first run — nobody has shared an agent yet — and on every roster error, landing beside an error state. That is the same defect this PR removes, handed back, and it is exactly the hazard the docblock reasons about for ROOMS without applying it to `empty` / `failed`. The fix is not to reserve less. Narrowing to `/workspace/c/:id` would protect the AC's route and leave the common path — the nav's `/workspace`, which redirects into a conversation — shifting as before, because the redirect happens after the roster lands. Instead the LEAVE is animated only when the column actually held a rail (`railEverHeldRail`): a reservation that paid off animates as before, one that did not is given back in a single frame. Measured after: EMPTY roster -> none -> reserved(48) -> none FAILED roster -> none -> reserved(48) -> none and the normal load is unchanged: `reserved(48) -> rail(48)`, no shift. The ENTER keeps its transition unconditionally — it only ever runs for the voice-canvas swap (#2676), since a reserved column is present from the first frame and never enters. The flag resets per route, because the question is about THIS stage: navigating from a conversation to an empty roster must not inherit the conversation's verdict. Two spec arms added for the second finding — the suite measured bare `/workspace`, not the `/workspace/c/<session>` the AC names, so it was right only by accident of the fixture: * a direct load of `/workspace/c/<id>` (resolved from the sessions route) keeps Send within 1px; * an empty and a failed roster give the column back with NO intermediate width. The assertion is about the shape of the removal, not its speed: every width observed must be the full reserved width or nothing, because an intermediate width IS an animation frame. Negative control: forcing the old unconditional leave fails it with "animated away through 6,22px". `portalVoiceLayoutMotion.spec.js` pinned the literal `leave-to-class="!w-0"`, which is now a binding. Updated rather than deleted: the #2676 property it protects is intact, since the canvas only ever takes the row from a rail that was on screen, so `railEverHeldRail` is true wherever that spec cares. Frontend unit suite 135 files / 2957 tests; the rail and model-choice e2e suites 12/12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
) Caught by chasing a red CI run rather than dismissing it. The reset watcher added with the reservation read `route.value.fullPath`, but `useRoute()` returns a reactive OBJECT, not a ref — every other route read in this 2,000-line file is the plain form, and this line was the only `route.value.` in it. It is not a silent no-op, it is a silent THROW: Vue routes a watch-getter error to its error handler instead of aborting setup, so the page rendered, the rail e2e passed 12/12, the unit suite passed 2957, and the watcher was dead the whole time while every Workspace load logged TypeError: Cannot read properties of undefined (reading 'fullPath') Verified in a browser before and after: the error is on the previous build and gone from this one. The measured rail behaviour is unchanged (`reserved(48) -> none` on an empty or failed roster, `reserved(48) -> rail(48)` normally) because the flag is only ever SET by the other watcher on a cold load; what was broken is the per-route reset, which is the in-session case. Guarded rather than just fixed: `portalRailReserve.spec.js` now fails on any `route.value.` in the shell, and the guard was proven to bite by reintroducing the bug (it fails) — a dead watcher is invisible to every test that does not read the console, so the spelling is worth pinning in the one file that mixes it. Unit suite 135 files / 2959 tests. The three workspace e2e suites CI flagged — rail-reserved, model-choice, stick-to-bottom — 15/15 locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…urce-text-pin (#2711) — mechanical, per the merge-train note on the PR The train (#2922's source-text ratchet + this stack) found the joint break: the `route.value` spelling guard reads Portal.vue by text and this is a NEW spec with no baseline entry. It is a legitimate pin — only the text proves a spelling is absent — so it declares itself one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85111e8 to
4347570
Compare
|
merge-train: rebased onto |
vybe
left a comment
There was a problem hiding this comment.
merge-train: validated (lane B), rebased onto dev, all checks green on the retargeted run.
Stacked on #2784 → #2783 → #2782 → #2781 → #2780 → #2778. Part of epic #1430.
Fixes #2711
The shift, measured
On every cold load the conversation column renders full width and then loses the rail's width when the roster arrives. Since #2676 it is a 300ms animated slide rather than a jump — prettier, still a shift, and the contract says loading and loaded share one footprint.
Before the fix, the Send button during load:
The fix
railVisibleForanswers may the rail render, and false for a non-ready stage is right — its tabs need the roster. But the column's width does not need the roster: it comes from the persisted rail state and is known synchronously at first paint.So the wrapper renders on
railHasColumn || railColumnReservedand gates onlyPortalRailinside. Space is reserved; content is not faked.What it deliberately does NOT reserve
railColumnReservedForis narrower thanrailVisibleFor's route set, and the exclusions are the interesting part:roomsAvailable, which arrives on the roster payload (bug(workspace): multi-agent chat picker is offered on builds with no rooms backend — 404 dead end #2128). Reserving against a capability we have not been told about yet would trade this shift for the opposite one on every install without rooms.So it reserves for exactly the case the bug is about: a 1:1 conversation route, mid-load. If the rail then turns out not to render, the column leaves through the existing width transition — a shrink, not a jump.
Motion is untouched
The
<Transition>classes, the #2676 voice-canvas swap andmotion-reduceare unchanged, and the reserved column is present from the first frame, so its enter never runs.Verification
workspace-model-choice.spec.js— the spec whose measurements the The Workspace rail column steps discretely when a voice call ends (#2640 follow-up) #2676 transition split, and which the issue asks to keep green — passes.One test removed rather than left skipping
An "agent page carries no rail column" e2e case asserted a premise the router does not hold:
/workspace/a/:nameredirects into a conversation, which legitimately has a rail. I found that by chasing the skip instead of accepting it. The rule it meant to check is covered in the unit spec, which does not depend on which URL the router settles on.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ