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() {
removeAiPending();
const parent = el("aiChatLog");
@@ -1421,7 +1428,7 @@
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>';
parent.appendChild(div);
parent.scrollTop = parent.scrollHeight;
scrollAiChatLog();
return div;
}
@@ -1484,19 +1491,26 @@
div.appendChild(actions);
}
parent.appendChild(div);
parent.scrollTop = parent.scrollHeight;
if (!parent.dataset.skipScroll) scrollAiChatLog();
}
function rebuildAiChatDom() {
if (el("aiChatLog")) el("aiChatLog").innerHTML = "";
const log = el("aiChatLog");
if (log) log.innerHTML = "";
if (!aiChatHistory.length) {
renderInkoStarters();
return;
}
aiChatHistory.forEach((m) => {
const who = m.role === "user" ? "user" : "bot";
appendAiChat(who, m.content, m.tools || null);
});
if (log) log.dataset.skipScroll = "1";
try {
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("open", show);
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) {
backdrop.hidden = !show;
backdrop.classList.toggle("show", show);
+89 -15
View File
@@ -585,15 +585,20 @@
body.kb-open .ctx-body {
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 {
padding-bottom: calc(12px + var(--kb-inset, 0px));
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
body.kb-open .ai-drawer-body {
padding-bottom: 8px;
body.kb-open .ai-drawer.kb-pinned {
transition: none;
}
.ai-drawer-head .inko-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ai-drawer-body {
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.user { background: rgba(255,255,255,0.04); border: 1px solid var(--border); }
@@ -1708,23 +1713,92 @@
window.__SC5_buildPhoneShareUrl = buildPhoneShareUrl;
})();
/* Mobile keyboard: keep focused inputs above keyboard */
/* Mobile keyboard: pin INKO drawer to visualViewport; avoid smooth-scroll jitter */
(function () {
const vv = window.visualViewport;
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));
document.documentElement.style.setProperty("--kb-inset", kb + "px");
document.body.classList.toggle("kb-open", kb > 60);
const a = document.activeElement;
if (kb > 60 && a && (a.tagName === "TEXTAREA" || a.tagName === "INPUT" || a.tagName === "SELECT")) {
try { a.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (_) {}
const open = kb > 80;
if (kb !== lastKb) {
document.documentElement.style.setProperty("--kb-inset", kb + "px");
document.body.classList.toggle("kb-open", open);
lastKb = kb;
}
};
vv.addEventListener("resize", apply);
vv.addEventListener("scroll", apply);
window.addEventListener("focusin", () => setTimeout(apply, 50));
window.addEventListener("focusout", () => setTimeout(apply, 100));
const drawer = document.getElementById("aiDrawer");
const drawerOpen = drawer && drawer.classList.contains("open") && !drawer.classList.contains("hidden");
if (drawerOpen && open) {
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>