web,ios: fix chat-window scrolling and focus-on-regain (#188)
Three fixes, all rooted in the original-chat-feed retirement leaving
native code + a workaround pointing at now-dead element ids.
- Composer growth no longer strands the feed ~a line above the bottom.
The lone re-pin (chat.js ResizeObserver) re-asserted scrollTop once via
rAF, which loses to WebKit's layout-time scrollTop restoration; re-pin
across a few frames so the correction always lands after it.
- iOS keyboard rise scrolls the live feed. applyInsets targeted the
retired, empty #feed; point it at the live .chatapp-feed.
- Focus-on-regain works again on desktop/Mac. The Mac injected
getElementById('text').focus() (dead #text); route through a new
shell.js window.lchatFocusComposer that selects the live composer, and
wire web visibilitychange/window-focus to it. Guarded to fine pointers
so touch never pops the keyboard on foreground.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 files changed,
+63
−16
changelog/2026-07-05-chat-scrolling-and-focus.md+7 −0ios/LChat/Views/ChatView.swift+5 −3ios/LChatMac/ChatFeedWebView.swift+7 −5web/static/apps/chat.js+14 −8web/static/shell.js+30 −0
diff --git a/changelog/2026-07-05-chat-scrolling-and-focus.md b/changelog/2026-07-05-chat-scrolling-and-focus.md| new file mode 100644 |
| index 0000000..8e206ce |
| --- /dev/null |
| +++ b/changelog/2026-07-05-chat-scrolling-and-focus.md |
| @@ -0,0 +1,7 @@ |
| +A few chat-window niggles are fixed. Coming back to Eleven now drops the cursor |
| +straight into the message box, so you can start typing without a tap (on the |
| +desktop and Mac apps — your phone won't pop the keyboard up on you just for |
| +switching back). And the conversation stays glued to the newest message when it |
| +should: the feed no longer settles a line above the bottom when your message box |
| +grows to several lines, and it rides up cleanly with the keyboard on iOS so the |
| +latest message is never tucked behind it. |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 54a1c1a..e20c566 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -477,7 +477,9 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| // the check always fails, stranding the newest message under the bars. |
| let js = """ |
| (function(){ |
| - var f = document.getElementById('feed'); |
| + // The live chat.js feed is .chatapp-feed (id-less); the original #feed |
| + // still sits empty in the skeleton, so target the live one first. |
| + var f = document.querySelector('.chatapp-feed') || document.getElementById('feed'); |
| var atBottom = f && (f.scrollHeight - f.scrollTop - f.clientHeight < 200); |
| var r = document.documentElement.style; |
| r.setProperty('--native-top-inset','\(Int(topInset))px'); |
| @@ -498,8 +500,8 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| // With the keyboard up, dragging the feed down until the finger meets the |
| // keyboard should carry it away with the drag, not leave it stranded open. |
| // That's UIScrollView's `.interactive` mode — but the feed scrolls inside a |
| - // nested overflow scroll view WebKit builds for `#feed` (the page body is |
| - // pinned at 100%, so the webview's own scrollView never scrolls). WebKit |
| + // nested overflow scroll view WebKit builds for the scrolling feed (the page |
| + // body is pinned at 100%, so the webview's own scrollView never scrolls). WebKit |
| // creates that inner scroller lazily and can rebuild it, so re-apply the mode |
| // whenever the page loads, the feed re-attaches, and — the moment that counts — |
| // the keyboard is about to show. |
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift| index f9b34e4..331a53d 100644 |
| --- a/ios/LChatMac/ChatFeedWebView.swift |
| +++ b/ios/LChatMac/ChatFeedWebView.swift |
| @@ -169,22 +169,24 @@ struct ChatFeedWebView: NSViewRepresentable { |
| deinit { NotificationCenter.default.removeObserver(self) } |
| // When Eleven regains focus, drop the caret into the selected instance's |
| - // message box (chat.js's #text) so you can start typing immediately. Only the |
| - // active instance reacts, and only when a composer is present. |
| + // message box so you can start typing immediately. Only the active instance |
| + // reacts, and only when a composer is present. |
| @objc private func appDidBecomeActive() { |
| guard app.selection == chat.id else { return } |
| focusComposer() |
| } |
| - // Put the caret in this instance's message box (chat.js's #text) so you can |
| - // type right away — used on app re-focus and after adding a file. No-op when |
| + // Put the caret in this instance's message box so you can type right away — |
| + // used on app re-focus and after adding a file. Routes through shell.js's |
| + // window.lchatFocusComposer, which targets the live (id-less) chat.js |
| + // composer and no-ops on touch / when another field is focused. No-op when |
| // no composer is present. |
| func focusComposer() { |
| guard let web = app.activeWebView ?? webView else { return } |
| DispatchQueue.main.async { |
| web.window?.makeFirstResponder(web) |
| web.evaluateJavaScript( |
| - "(function(){var t=document.getElementById('text'); if (t) t.focus();})()", |
| + "window.lchatFocusComposer && window.lchatFocusComposer()", |
| completionHandler: nil) |
| } |
| } |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index a71f015..c7ee5e7 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -1993,15 +1993,21 @@ function buildSkeleton(el, ctx) { |
| ); |
| new ResizeObserver(() => { |
| if (!feedAtBottom) return; |
| - feed.scrollTop = feed.scrollHeight; |
| - // WebKit restores the pre-resize scrollTop *after* observers run, undoing |
| - // the line above (Chrome doesn't); re-assert next frame, once that |
| - // restoration has happened — unconditionally, since the was-at-bottom call |
| - // was made above and the restoration's own scroll event may have already |
| - // flipped feedAtBottom false. |
| - requestAnimationFrame(() => { |
| + // WebKit restores the pre-resize scrollTop at *layout* time — which lands |
| + // after this callback AND after a single rAF — so one re-assert loses the |
| + // race and strands the feed the growth-delta (~one composer line) above the |
| + // bottom (Chrome doesn't do this). Re-pin across a few frames so a |
| + // correction always lands after the restoration, whenever it happens. The |
| + // scroll listener keeps feedAtBottom true through the restoration (its gap |
| + // stays under the 60px threshold), so this bails only if the reader |
| + // genuinely scrolls up mid-window. |
| + let frame = 0; |
| + const pin = () => { |
| + if (!feedAtBottom) return; // reader scrolled away — stop fighting them |
| feed.scrollTop = feed.scrollHeight; |
| - }); |
| + if (++frame < 4) requestAnimationFrame(pin); |
| + }; |
| + pin(); |
| }).observe(feed); |
| // ---- composer state (per mount; a fresh open rebuilds it clean) ---- |
diff --git a/web/static/shell.js b/web/static/shell.js| index a75dae1..2dadcbf 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -956,9 +956,39 @@ function startHeartbeat() { |
| if (document.visibilityState === "visible") { |
| checkForUpdate(); |
| resyncActive(); |
| + focusChatComposer(); |
| } |
| }); |
| + // ...and when the window itself regains focus (clicking back into the app from |
| + // another window/app, where the tab stayed "visible" so the handler above |
| + // doesn't fire). Both routes drop the caret into the composer so you're ready |
| + // to type — the Mac app does the same on didBecomeActive. |
| + window.addEventListener("focus", focusChatComposer); |
| +} |
| + |
| +// Drop the caret into the live chat composer (the id-less chat.js <textarea> |
| +// inside .chatapp-root) when the app regains focus. Desktop/Mac only: on touch, |
| +// refocusing would pop the on-screen keyboard up on every foreground. Scoped by |
| +// selector rather than a stashed reference so it always targets the currently |
| +// connected root (survives the instant-restore path, which re-attaches a parked |
| +// root without rebuilding). Exposed as a global so the Mac shell can call it too |
| +// — it runs the full web UI, not the embed-only window.lchatNative bridge. |
| +function focusChatComposer() { |
| + if (isCoarse) return; |
| + const ta = document.querySelector(".chatapp-root .composer textarea"); |
| + if (!ta || ta === document.activeElement) return; |
| + // Don't yank the caret out of another field the user left mid-edit (search, |
| + // an inline rename, …) — only auto-focus when nothing editable holds focus. |
| + const active = document.activeElement; |
| + if ( |
| + active && |
| + active !== document.body && |
| + (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable) |
| + ) |
| + return; |
| + ta.focus(); |
| } |
| +window.lchatFocusComposer = focusChatComposer; |
| // A one-line snippet for the chat list: "Name: text" in groups, just text in DMs. |
| function previewText(t, data) { |