eleven / ocho Public

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>
Paul Campbell 7692141a95d972f2248179075465fc5bb09d12d3 parent cf63e28
5 files changed, +63 −16
  • changelog/2026-07-05-chat-scrolling-and-focus.md +7 −0
  • ios/LChat/Views/ChatView.swift +5 −3
  • ios/LChatMac/ChatFeedWebView.swift +7 −5
  • web/static/apps/chat.js +14 −8
  • web/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) {