Merge pull request #78 from SquidSec/fix/inko-mobile-kb-jitter

fix(ops): INKO mobile keyboard scroll jitter
This commit is contained in:
☣️ Mr. The Plague ☣️
2026-08-02 12:01:26 -05:00
committed by GitHub
2 changed files with 117 additions and 22 deletions
+28 -7
View File
@@ -1411,6 +1411,13 @@
}); });
} }
function scrollAiChatLog() {
const parent = el("aiChatLog");
if (!parent) return;
// Instant only - smooth + keyboard resize causes mobile jitter
parent.scrollTop = parent.scrollHeight;
}
function showAiPending() { function showAiPending() {
removeAiPending(); removeAiPending();
const parent = el("aiChatLog"); const parent = el("aiChatLog");
@@ -1421,7 +1428,7 @@
div.innerHTML = div.innerHTML =
'<div class="who">INKO</div><div class="ai-typing"><span class="ai-typing-dots" aria-hidden="true"><span></span><span></span><span></span></span><span>Thinking...</span></div>'; '<div class="who">INKO</div><div class="ai-typing"><span class="ai-typing-dots" aria-hidden="true"><span></span><span></span><span></span></span><span>Thinking...</span></div>';
parent.appendChild(div); parent.appendChild(div);
parent.scrollTop = parent.scrollHeight; scrollAiChatLog();
return div; return div;
} }
@@ -1484,19 +1491,26 @@
div.appendChild(actions); div.appendChild(actions);
} }
parent.appendChild(div); parent.appendChild(div);
parent.scrollTop = parent.scrollHeight; if (!parent.dataset.skipScroll) scrollAiChatLog();
} }
function rebuildAiChatDom() { function rebuildAiChatDom() {
if (el("aiChatLog")) el("aiChatLog").innerHTML = ""; const log = el("aiChatLog");
if (log) log.innerHTML = "";
if (!aiChatHistory.length) { if (!aiChatHistory.length) {
renderInkoStarters(); renderInkoStarters();
return; return;
} }
aiChatHistory.forEach((m) => { if (log) log.dataset.skipScroll = "1";
const who = m.role === "user" ? "user" : "bot"; try {
appendAiChat(who, m.content, m.tools || null); aiChatHistory.forEach((m) => {
}); const who = m.role === "user" ? "user" : "bot";
appendAiChat(who, m.content, m.tools || null);
});
} finally {
if (log) delete log.dataset.skipScroll;
}
scrollAiChatLog();
} }
@@ -1720,6 +1734,13 @@
d.classList.toggle("hidden", !show); d.classList.toggle("hidden", !show);
d.classList.toggle("open", show); d.classList.toggle("open", show);
d.setAttribute("aria-hidden", show ? "false" : "true"); d.setAttribute("aria-hidden", show ? "false" : "true");
if (!show) {
// Drop visualViewport pin so next open starts full-screen again
d.classList.remove("kb-pinned");
d.style.top = "";
d.style.height = "";
d.style.bottom = "";
}
if (backdrop) { if (backdrop) {
backdrop.hidden = !show; backdrop.hidden = !show;
backdrop.classList.toggle("show", show); backdrop.classList.toggle("show", show);
+89 -15
View File
@@ -585,15 +585,20 @@
body.kb-open .ctx-body { body.kb-open .ctx-body {
padding-bottom: calc(24px + var(--kb-inset, 0px)); padding-bottom: calc(24px + var(--kb-inset, 0px));
} }
/* INKO drawer is resized to visualViewport when keyboard is open - do not
also pad the foot by kb-inset (that double-compensates and jitters the log). */
body.kb-open .ai-drawer-foot { body.kb-open .ai-drawer-foot {
padding-bottom: calc(12px + var(--kb-inset, 0px)); padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
} }
body.kb-open .ai-drawer-body { body.kb-open .ai-drawer.kb-pinned {
padding-bottom: 8px; transition: none;
} }
.ai-drawer-head .inko-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .ai-drawer-head .inko-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ai-drawer-body { .ai-drawer-body {
flex: 1; overflow: auto; padding: 10px; font-size: 0.82rem; min-height: 0; flex: 1; overflow: auto; padding: 10px; font-size: 0.82rem; min-height: 0;
overflow-anchor: none;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
} }
.ai-msg { margin-bottom: 10px; padding: 8px 10px; border-radius: 8px; line-height: 1.45; } .ai-msg { margin-bottom: 10px; padding: 8px 10px; border-radius: 8px; line-height: 1.45; }
.ai-msg.user { background: rgba(255,255,255,0.04); border: 1px solid var(--border); } .ai-msg.user { background: rgba(255,255,255,0.04); border: 1px solid var(--border); }
@@ -1708,23 +1713,92 @@
window.__SC5_buildPhoneShareUrl = buildPhoneShareUrl; window.__SC5_buildPhoneShareUrl = buildPhoneShareUrl;
})(); })();
/* Mobile keyboard: keep focused inputs above keyboard */ /* Mobile keyboard: pin INKO drawer to visualViewport; avoid smooth-scroll jitter */
(function () { (function () {
const vv = window.visualViewport; const vv = window.visualViewport;
if (!vv) return; if (!vv) return;
const apply = () => { let raf = 0;
let lastKb = -1;
let lastTop = -1;
let lastH = -1;
function pinChatLog() {
const log = document.getElementById("aiChatLog");
if (!log) return;
// Instant snap only - never smooth (smooth + vv resize = jitter)
log.scrollTop = log.scrollHeight;
}
function clearDrawerPin(drawer) {
if (!drawer) return;
drawer.classList.remove("kb-pinned");
drawer.style.top = "";
drawer.style.height = "";
drawer.style.bottom = "";
}
function apply(opts) {
const kb = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)); const kb = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
document.documentElement.style.setProperty("--kb-inset", kb + "px"); const open = kb > 80;
document.body.classList.toggle("kb-open", kb > 60); if (kb !== lastKb) {
const a = document.activeElement; document.documentElement.style.setProperty("--kb-inset", kb + "px");
if (kb > 60 && a && (a.tagName === "TEXTAREA" || a.tagName === "INPUT" || a.tagName === "SELECT")) { document.body.classList.toggle("kb-open", open);
try { a.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (_) {} lastKb = kb;
} }
};
vv.addEventListener("resize", apply); const drawer = document.getElementById("aiDrawer");
vv.addEventListener("scroll", apply); const drawerOpen = drawer && drawer.classList.contains("open") && !drawer.classList.contains("hidden");
window.addEventListener("focusin", () => setTimeout(apply, 50)); if (drawerOpen && open) {
window.addEventListener("focusout", () => setTimeout(apply, 100)); const top = Math.round(vv.offsetTop);
const h = Math.max(120, Math.round(vv.height));
if (top !== lastTop || h !== lastH || !drawer.classList.contains("kb-pinned")) {
drawer.classList.add("kb-pinned");
drawer.style.top = top + "px";
drawer.style.height = h + "px";
drawer.style.bottom = "auto";
lastTop = top;
lastH = h;
}
} else if (drawer) {
clearDrawerPin(drawer);
lastTop = -1;
lastH = -1;
}
if (opts && opts.pinChat && drawerOpen) pinChatLog();
}
function schedule(pinChat) {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
raf = 0;
apply({ pinChat: !!pinChat });
});
}
vv.addEventListener("resize", () => schedule(false));
vv.addEventListener("scroll", () => schedule(false));
window.addEventListener("focusin", (e) => {
const a = e.target;
if (!a || !a.tagName) return;
const tag = a.tagName;
if (tag !== "TEXTAREA" && tag !== "INPUT" && tag !== "SELECT") return;
const inInko = !!(a.id === "aiPromptGlobal" || a.closest("#aiDrawer"));
// One settle after keyboard starts - no smooth scrollIntoView on the message log
setTimeout(() => {
apply({ pinChat: inInko });
try {
a.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
} catch (_) {
try { a.scrollIntoView(false); } catch (__) {}
}
}, 60);
});
window.addEventListener("focusout", () => {
setTimeout(() => apply({ pinChat: false }), 120);
});
})(); })();
</script> </script>