Center the composer on a fresh chat - #2452
Conversation
Before the first message the composer no longer sits at the bottom of an empty column: it centers vertically with the app greeting above it, and docks to the bottom as soon as the conversation has content — the layout every other chat app uses for its new-chat screen. - ChatWindow: the composer overlay switches between `bottom-0` (docked) and `bottom-1/2 translate-y-1/2` (centered). Pure CSS, so server-rendered markup already lands centered and nothing jumps on hydration. - `max-h-full` + `justify-end` clamp the centered box to the column height, so a group taller than the viewport (long draft plus attachments on a short screen) falls back to the docked position instead of pushing the send button below the fold. - The greeting moved out of the scroll column and above the composer; clientHeight is now bound to an inner wrapper so the greeting can never inflate the clearance the scroll spacer derives from it. - Touch devices dock while the input is focused: the virtual keyboard covers the lower half of the screen and fires no ResizeObserver, so a centered composer would end up behind it. - "Fresh" counts rendered messages only, so a conversation created but never sent to — it still carries an invisible `system` preprompt message — gets the same screen instead of a blank column, and the example chips and caveat line show there too. Verified in Chromium (desktop 1280x800/520, tablet, iPhone 13 portrait and landscape, 320x568) against both dev and production builds: centering is exact in every viewport, docking is unchanged after a send, and the keyboard-focus/blur, attachment, drag-drop and dropdown paths all behave. 469 unit tests and 32 e2e specs pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s35VQavGEgfwSAR15kuok
The starter chips above a fresh composer were mounted and unmounted, and inside a vertically centered group that moved everything: measured at 1280x800, the input jumped 18px up and the greeting 18px down on the first keystroke, and the same 18px the other way when the MCP store hydrated and the row appeared — a visible pop on every load of the new-chat screen. Both rows now split "does this row belong on this screen" from "should its chips be showing", keeping the box and fading only the chips. The chips stay rendered and hidden with `invisible`, so the reserved height is the row's real height rather than a hardcoded one, and hidden chips leave the tab order and the accessibility tree. For the hydration half, the row's presence has to be knowable during SSR: `mcpServersLoaded` is false there, so it reads the base-server list out of the SSR payload — base servers are enabled unless the user turned them off — rather than guessing. A deployment with no MCP servers configured reserves nothing, so no dead space appears where the row will never show. Verified in Chromium against a dev server in both configurations: with MCP servers configured, the input top and greeting top are identical across SSR paint, hydration, idle and typing (406 / 262); with MCP_SERVERS=[] nothing is reserved and the geometry is unchanged from before (388 / 280). The follow-up row behaves the same after a first exchange. 469 unit tests and 32 e2e specs pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s35VQavGEgfwSAR15kuok
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5be62461ce
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ); | ||
| let toolExamplesApply = $derived( | ||
| currentModel.isRouter || | ||
| (modelSupportsTools && ($mcpServersLoaded ? $allBaseServersEnabled : baseMcpServerCount > 0)) |
There was a problem hiding this comment.
Preserve row geometry for disabled MCP preferences
When a returning user has disabled one of the configured base MCP servers, the SSR branch still treats baseMcpServerCount > 0 as though all servers were enabled and reserves the hidden examples row. During hydration, initWithServers restores the disabled IDs from localStorage, $allBaseServersEnabled becomes false, and this expression removes the row for a fresh non-router tool model, visibly shifting the newly centered composer. Avoid changing the reservation solely when the client-side disabled-server preference hydrates.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 9793147. Reproduced at 1280x800 by writing a configured base server's id into chatui:mcp:disabled-base-ids: input top 406 at SSR paint, 388 after hydration.
Worth noting it's wider than "disabled servers" — allBaseServersEnabled requires every base server to be selected, so turning off a single one is enough to trigger it.
The reservation now reads only the configured base-server list from the SSR payload, which server and client agree on, and $allBaseServersEnabled moves to the chips-visibility condition where the client is the authority. The trade is that a user in this state keeps an empty reserved row on a non-router tools model — invisible whitespace in a centered layout, where a jump on every page load is not.
Verified across SSR paint, hydration and first keystroke in three configurations: base servers enabled (chips show, 406/262 throughout), every base server disabled (row reserved and empty, 406/262 throughout), and MCP_SERVERS=[] (nothing reserved, 388/280 throughout).
Generated by Claude Code
Reserving on `$allBaseServersEnabled` put the row back in the hands of a client-only preference. A returning user who has turned off any one configured base MCP server — `allBaseServersEnabled` requires every one of them, so a single toggle is enough — got the row reserved during SSR and dropped once `initWithServers` restored the disabled ids from localStorage: on a non-router tools model the composer moved 18px at hydration, exactly the shift this split exists to remove. Reproduced at 1280x800, input top 406 -> 388. The reservation now reads only the configured base-server list from the SSR payload, which the server and the client agree on, and `$allBaseServersEnabled` moves to the chips-visibility condition where the client is the authority. Such a user keeps an empty reserved row on a non-router tools model, which is invisible whitespace in a centered layout — a jump on every page load is not. Verified against a dev server in three configurations, checking SSR paint, hydration and first keystroke: base servers enabled (chips show, 406/262 throughout), every base server disabled (row reserved and empty, 406/262 throughout), and MCP_SERVERS=[] (nothing reserved, 388/280 throughout). 469 unit tests pass. Reported-by: chatgpt-codex-connector in PR review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s35VQavGEgfwSAR15kuok
Three things still moved after the server-rendered markup appeared, all because the composer's layout was reading values the server does not have. Measured on a production build at 1280x800, sampling every frame from navigation: - The input jumped 10px right and narrowed by 20px. `--scrollbar-gutter` can only be measured from the live scroll container, so SSR emits 0 and hydration writes the real half-gutter into the composer's padding. It is held at zero while the composer is centered: there are no messages to align with yet, and when the composer docks the compensation rides along with a several-hundred-pixel vertical move. - Every chip's label changed — "HTML game" to "Generate an image" — because the starter set was chosen by `$allBaseServersEnabled`, a store that only fills in at hydration. The set now comes from the configured base-server list in the SSR payload, the same fact the server already has. - The chips faded in ~380ms after first paint, gated on `$mcpServersLoaded`. Throttled to 1.6Mbps they were still invisible 4.2s in, which is what "sometimes the examples show, sometimes not" was: they wait for the bundle. The gate is gone, so the chips ship visible in the server HTML. The MCP store's one unique fact is which servers the user has since switched off, held in localStorage. That is deliberately no longer consulted here — honouring it would put first paint back in the hands of a value the server cannot see. A user who turned MCP off now sees tool-flavoured starters, which are prompts they can send either way. Before: 4 distinct layout states between first paint and settled, or 2 with the chips never arriving when throttled. After: one state, fast and throttled alike. Typing still hides the chips without moving the composer, docking still aligns composer text with message text (365 vs 362), and the disabled-server and no-MCP deployments are stable across hydration too. 469 unit tests and 32 e2e specs pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s35VQavGEgfwSAR15kuok
Before the first message the composer no longer sits at the bottom of an
empty column: it centers vertically with the app greeting above it, and
docks to the bottom as soon as the conversation has content — the layout
every other chat app uses for its new-chat screen.
bottom-0(docked)and
bottom-1/2 translate-y-1/2(centered). Pure CSS, so server-renderedmarkup already lands centered and nothing jumps on hydration.
max-h-full+justify-endclamp the centered box to the column height,so a group taller than the viewport (long draft plus attachments on a
short screen) falls back to the docked position instead of pushing the
send button below the fold.
clientHeight is now bound to an inner wrapper so the greeting can never
inflate the clearance the scroll spacer derives from it.
covers the lower half of the screen and fires no ResizeObserver, so a
centered composer would end up behind it.
never sent to — it still carries an invisible
systempreprompt message— gets the same screen instead of a blank column, and the example chips
and caveat line show there too.
Verified in Chromium (desktop 1280x800/520, tablet, iPhone 13 portrait and
landscape, 320x568) against both dev and production builds: centering is
exact in every viewport, docking is unchanged after a send, and the
keyboard-focus/blur, attachment, drag-drop and dropdown paths all behave.
469 unit tests and 32 e2e specs pass.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_011s35VQavGEgfwSAR15kuok