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

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

No files matched your search

+6
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; }
+15
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();
+1
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>
+92
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);
},
};
};
});
+3 -1
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',
];