feat: keep mobile conversation replies keyboard-safe (#323)
All checks were successful
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy 2026-08-08 17:34:11 +00:00
parent 160753caf8
commit 21e0a49d3c
8 changed files with 289 additions and 4 deletions

View File

@ -310,6 +310,12 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-sheet-panel.composer-keyboard-active,
.pull-sheet-panel.composer-keyboard-active,
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.composer-keyboard-active .issue-comment-composer,
.composer-keyboard-active .pull-comment-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }

View File

@ -1,6 +1,16 @@
(function(){
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
const mobileComposerViewport = createMobileComposerViewport({
viewport: window.visualViewport,
mediaQuery: window.matchMedia('(max-width: 600px)'),
entries: [
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
],
});
mobileComposerViewport.start();
[
[qs('.app-menu'), qs('#app-menu-toggle')],
[qs('.work-settings'), qs('#work-settings-toggle')],
@ -515,6 +525,8 @@
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#update-sheet .update-sheet-panel']
.forEach(selector => mobileComposerViewport.close(qs(selector)));
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
qs(selector).classList.remove('open')
);
@ -1206,6 +1218,7 @@
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueDetail = null;
@ -1336,6 +1349,7 @@
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
@ -1749,6 +1763,7 @@
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();

View File

@ -536,6 +536,7 @@
<script src="static/mobile-launch.js"></script>
<script src="static/install-app.js"></script>
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>

View File

@ -0,0 +1,92 @@
(function (root, factory) {
const exported = factory();
if (typeof module === 'object' && module.exports) module.exports = exported;
else root.createMobileComposerViewport = exported;
})(typeof self !== 'undefined' ? self : this, function () {
return function createMobileComposerViewport(options) {
const entries = options.entries || [];
const viewport = options.viewport;
const mediaQuery = options.mediaQuery;
const documentRef = options.document || document;
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
let active = null;
let started = false;
function reveal() {
if (!active) return;
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
}
function applyGeometry() {
if (!active || !viewport || !mediaQuery.matches) return;
active.panel.style.setProperty('--composer-viewport-top', `${viewport.offsetTop || 0}px`);
active.panel.style.setProperty('--composer-viewport-height', `${viewport.height}px`);
reveal();
}
function onViewportChange() {
schedule(applyGeometry);
}
function activate(entry) {
if (!viewport || !mediaQuery.matches) return;
if (active === entry) {
schedule(applyGeometry);
return;
}
if (active) deactivate(active, false);
active = entry;
entry.scrollTop = entry.panel.scrollTop;
entry.panel.classList?.add('composer-keyboard-active');
viewport.addEventListener('resize', onViewportChange);
viewport.addEventListener('scroll', onViewportChange);
applyGeometry();
}
function deactivate(entry, restore = true) {
if (active !== entry) return;
viewport?.removeEventListener('resize', onViewportChange);
viewport?.removeEventListener('scroll', onViewportChange);
entry.panel.classList?.remove('composer-keyboard-active');
entry.panel.style.removeProperty('--composer-viewport-top');
entry.panel.style.removeProperty('--composer-viewport-height');
active = null;
if (restore) schedule(() => { entry.panel.scrollTop = entry.scrollTop; });
}
function bind(entry) {
entry.onFocus = () => activate(entry);
entry.onFocusOut = () => schedule(() => {
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
});
entry.composer.addEventListener('focus', entry.onFocus);
entry.workspace.addEventListener('focusout', entry.onFocusOut);
}
function unbind(entry) {
entry.composer.removeEventListener('focus', entry.onFocus);
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
}
return {
start() {
if (started) return;
started = true;
entries.forEach(bind);
},
close(panel) {
const entry = entries.find(candidate => candidate.panel === panel);
if (entry) deactivate(entry, false);
},
stop() {
if (!started) return;
if (active) deactivate(active, false);
entries.forEach(unbind);
started = false;
},
revealActive() {
schedule(applyGeometry);
},
};
};
});

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v35';
const CACHE = 'stackchain-dashboard-shell-v36';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@ -32,11 +32,13 @@ const SHELL = [
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/work-route.js',
BASE + 'static/task-overlay-history.js',
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/background-issue-sync.js',
];

View File

@ -0,0 +1,38 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
HTML = ROOT / "frontend" / "index.html"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
CSS = ROOT / "frontend" / "dashboard.css"
WORKER = ROOT / "frontend" / "service-worker.js"
def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert '<script src="static/mobile-composer-viewport.js"></script>' in html
assert "createMobileComposerViewport({" in dashboard
for panel, workspace, composer, submit, status in [
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
]:
for selector in (panel, workspace, composer, submit, status):
assert repr(selector) in dashboard
assert "height:var(--composer-viewport-height,100dvh)" in css
assert "top:var(--composer-viewport-top,0px)" in css
assert ".composer-keyboard-active" in css
def test_offline_shell_contains_every_local_dashboard_runtime_asset():
html = HTML.read_text()
worker = WORKER.read_text()
local_assets = set(re.findall(r'(?:src|href)="((?:static/|manifest\.webmanifest)[^"?#]*)', html))
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v36" in worker

View File

@ -0,0 +1,129 @@
import json
import subprocess
from pathlib import Path
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js"
def run_controller_scenario(scenario: str) -> dict:
harness = f"""
const createController = require({json.dumps(str(CONTROLLER))});
class Target {{
constructor() {{ this.listeners = {{}}; }}
addEventListener(name, handler) {{ (this.listeners[name] ||= new Set()).add(handler); }}
removeEventListener(name, handler) {{ this.listeners[name]?.delete(handler); }}
dispatch(name, event = {{}}) {{ for (const handler of this.listeners[name] || []) handler(event); }}
count(name) {{ return this.listeners[name]?.size || 0; }}
}}
function element() {{
const target = new Target();
target.style = {{
values: {{}},
setProperty(name, value) {{ this.values[name] = value; }},
removeProperty(name) {{ delete this.values[name]; }},
}};
target.scrollTop = 0;
target.reveals = [];
target.scrollIntoView = options => target.reveals.push(options);
target.contains = candidate => candidate === target || candidate?.workspace === target;
return target;
}}
const viewport = new Target();
viewport.offsetTop = 12;
viewport.height = 360;
const mediaQuery = {{matches:true}};
const panel = element();
const workspace = element();
const composer = element(); composer.workspace = workspace;
const submit = element(); submit.workspace = workspace;
const status = element(); status.workspace = workspace;
const document = {{activeElement:null}};
const scheduled = [];
const controller = createController({{
entries:[{{panel, workspace, composer, submit, status}}], viewport, mediaQuery, document,
schedule: callback => scheduled.push(callback),
}});
function flush() {{ while (scheduled.length) scheduled.shift()(); }}
{scenario}
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_mobile_composer_uses_visual_viewport_and_reveals_delivery_controls():
result = run_controller_scenario(
"""
controller.start();
panel.scrollTop = 240;
document.activeElement = composer;
composer.dispatch('focus');
viewport.offsetTop = 20;
viewport.height = 300;
viewport.dispatch('resize');
flush();
process.stdout.write(JSON.stringify({
geometry: panel.style.values,
resizeListeners: viewport.count('resize'),
scrollListeners: viewport.count('scroll'),
revealCount: workspace.reveals.length,
revealOptions: workspace.reveals.at(-1),
}));
"""
)
assert result == {
"geometry": {
"--composer-viewport-top": "20px",
"--composer-viewport-height": "300px",
},
"resizeListeners": 1,
"scrollListeners": 1,
"revealCount": 2,
"revealOptions": {"block": "end", "inline": "nearest"},
}
def test_blur_restores_scroll_and_close_clears_geometry_without_duplicate_listeners():
result = run_controller_scenario(
"""
controller.start();
controller.start();
panel.scrollTop = 240;
document.activeElement = composer;
composer.dispatch('focus');
composer.dispatch('focus');
document.activeElement = null;
workspace.dispatch('focusout');
flush();
const afterBlur = {scrollTop:panel.scrollTop, geometry:{...panel.style.values}, resize:viewport.count('resize')};
panel.scrollTop = 99;
document.activeElement = composer;
composer.dispatch('focus');
controller.close(panel);
flush();
process.stdout.write(JSON.stringify({
afterBlur,
afterClose:{scrollTop:panel.scrollTop, geometry:panel.style.values, resize:viewport.count('resize'), scroll:viewport.count('scroll')},
}));
"""
)
assert result == {
"afterBlur": {"scrollTop": 240, "geometry": {}, "resize": 0},
"afterClose": {"scrollTop": 99, "geometry": {}, "resize": 0, "scroll": 0},
}
def test_desktop_and_missing_visual_viewport_are_noop():
result = run_controller_scenario(
"""
mediaQuery.matches = false;
controller.start();
document.activeElement = composer;
composer.dispatch('focus');
process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewport.count('resize'), reveals:workspace.reveals.length}));
"""
)
assert result == {"geometry": {}, "resize": 0, "reveals": 0}

View File

@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v35" in source
assert "stackchain-dashboard-shell-v36" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v35" in source
assert "stackchain-dashboard-shell-v36" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v35" in source
assert "stackchain-dashboard-shell-v36" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -296,11 +296,13 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",
"/dashboard/static/task-overlay-history.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/background-issue-sync.js",
}