From 21e0a49d3cd8a0dbc15c15ba3fa4bbc1819dae59 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 17:34:11 +0000 Subject: [PATCH] feat: keep mobile conversation replies keyboard-safe (#323) --- frontend/dashboard.css | 6 + frontend/dashboard.js | 15 +++ frontend/index.html | 1 + frontend/mobile-composer-viewport.js | 92 +++++++++++++++ frontend/service-worker.js | 4 +- tests/test_mobile_composer_integration.py | 38 +++++++ tests/test_mobile_composer_viewport.py | 129 ++++++++++++++++++++++ tests/test_service_worker.py | 8 +- 8 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 frontend/mobile-composer-viewport.js create mode 100644 tests/test_mobile_composer_integration.py create mode 100644 tests/test_mobile_composer_viewport.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index cdc3616..315f17a 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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,') center/contain no-repeat; display:inline-block; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 47bb3e9..3d73f55 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -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(); diff --git a/frontend/index.html b/frontend/index.html index 2e6aa8f..8e02503 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -536,6 +536,7 @@ + diff --git a/frontend/mobile-composer-viewport.js b/frontend/mobile-composer-viewport.js new file mode 100644 index 0000000..b4cc250 --- /dev/null +++ b/frontend/mobile-composer-viewport.js @@ -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); + }, + }; + }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 97376b1..7b27518 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -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', ]; diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py new file mode 100644 index 0000000..7d0c365 --- /dev/null +++ b/tests/test_mobile_composer_integration.py @@ -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 '' 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 diff --git a/tests/test_mobile_composer_viewport.py b/tests/test_mobile_composer_viewport.py new file mode 100644 index 0000000..ed55b34 --- /dev/null +++ b/tests/test_mobile_composer_viewport.py @@ -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} diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 9fa03c3..2c3b5d1 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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", } -- 2.43.0