diff --git a/frontend/src/components/AppCanvas/AppCanvas.css b/frontend/src/components/AppCanvas/AppCanvas.css
index f5ed089f..35e88d41 100644
--- a/frontend/src/components/AppCanvas/AppCanvas.css
+++ b/frontend/src/components/AppCanvas/AppCanvas.css
@@ -197,3 +197,46 @@
color: var(--muted, #999);
font-family: var(--font, inherit);
}
+
+.canvas-loading__offline-actions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 2px;
+}
+
+.canvas-loading__offline-button {
+ min-height: 44px;
+ padding: 10px 16px;
+ border: 1px solid var(--border, #333);
+ border-radius: 10px;
+ background: var(--surface, #1a1a1a);
+ color: var(--text, #e5e5e5);
+ font-family: var(--font, inherit);
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1;
+ max-width: 100%;
+ cursor: pointer;
+}
+
+.canvas-loading__offline-button--primary {
+ border-color: var(--accent, #8b6cf7);
+ background: var(--accent, #8b6cf7);
+ color: var(--accent-fg, #fff);
+}
+
+.canvas-loading__offline-button--secondary {
+ background: transparent;
+}
+
+.canvas-loading__offline-button:active {
+ transform: scale(0.97);
+}
+
+.canvas-loading__offline-button:focus-visible {
+ outline: 2px solid var(--accent, #8b6cf7);
+ outline-offset: 2px;
+}
diff --git a/frontend/src/components/AppCanvas/AppCanvas.jsx b/frontend/src/components/AppCanvas/AppCanvas.jsx
index 4aeccdde..b6497abb 100644
--- a/frontend/src/components/AppCanvas/AppCanvas.jsx
+++ b/frontend/src/components/AppCanvas/AppCanvas.jsx
@@ -899,16 +899,24 @@ export default function AppCanvas({
? 'Open it when you’re ready, or switch to another app.'
: serviceSurface.error}
-
-
diff --git a/frontend/src/components/ChatView/ChatView.jsx b/frontend/src/components/ChatView/ChatView.jsx
index 023a5b6b..601cd556 100644
--- a/frontend/src/components/ChatView/ChatView.jsx
+++ b/frontend/src/components/ChatView/ChatView.jsx
@@ -79,6 +79,7 @@ import {
saveFailedSendAttempt,
sendAttemptIsDurable,
} from './sendAttemptRecovery.js'
+import { persistComposerDraft } from './composerDraft.js'
import {
EMPTY_BUILD_PHASE_RAIL,
accumulateBuildPhase,
@@ -169,27 +170,6 @@ export function deleteChatDraft(chatId) {
clearChatQuestionDrafts(chatId)
}
-// Evict the oldest draft: key from sessionStorage so a new draft can land.
-// Oldest = smallest numeric suffix after the colon; that's the chat that
-// was least recently opened (chats get integer IDs assigned in order).
-function evictOldestDraft() {
- try {
- const draftKeys = []
- for (let i = 0; i < sessionStorage.length; i++) {
- const key = sessionStorage.key(i)
- if (key?.startsWith('draft:')) draftKeys.push(key)
- }
- if (draftKeys.length === 0) return
- // Sort ascending by the numeric part; remove the oldest (lowest ID).
- draftKeys.sort((a, b) => {
- const na = parseInt(a.slice(6), 10) || 0
- const nb = parseInt(b.slice(6), 10) || 0
- return na - nb
- })
- sessionStorage.removeItem(draftKeys[0])
- } catch { /* ignore */ }
-}
-
function tailResumableBlock(messages) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].hidden) continue
@@ -358,7 +338,14 @@ export default function ChatView({
if (!initialComposerRef.current) {
initialComposerRef.current = readInitialComposer(chatId)
}
- const [input, setInput] = useState(() => initialComposerRef.current.input)
+ const [input, setInputState] = useState(() => initialComposerRef.current.input)
+ function setComposerInput(nextInput) {
+ // Navigation can unmount this component before React flushes passive
+ // effects. Keep every composer transition durable at the state boundary,
+ // whether it came from typing, voice, restoration, or send cleanup.
+ persistComposerDraft(chatId, nextInput)
+ setInputState(nextInput)
+ }
const [sendFailure, setSendFailure] = useState(() => (
initialComposerRef.current.failedAttempt
? 'Möbius is checking whether your previous message reached the chat…'
@@ -1407,7 +1394,7 @@ export default function ChatView({
function handleComposerInputChange(nextInput) {
clearFailedAttempt()
setSendFailure(null)
- setInput(nextInput)
+ setComposerInput(nextInput)
}
function handleComposerAddFiles(fileList) {
@@ -1426,7 +1413,7 @@ export default function ChatView({
text,
{ focus = false, preserveFailedAttempt = false } = {},
) {
- if (preserveFailedAttempt) setInput(text)
+ if (preserveFailedAttempt) setComposerInput(text)
else handleComposerInputChange(text)
requestAnimationFrame(() => {
const el = inputRef.current
@@ -1540,21 +1527,11 @@ export default function ChatView({
}
// Persist draft so it survives leaving and re-entering the chat.
+ // This remains as a safety net for programmatic input changes (restores,
+ // voice transcription, send cleanup). Direct owner edits are saved
+ // synchronously in handleComposerInputChange above.
useEffect(() => {
- try {
- if (input) sessionStorage.setItem(`draft:${chatId}`, input)
- else sessionStorage.removeItem(`draft:${chatId}`)
- } catch (e) {
- // QuotaExceededError: evict the oldest draft: key and retry once so the
- // current chat's draft is always fresh.
- if (e?.name === 'QuotaExceededError' || e?.code === 22) {
- evictOldestDraft()
- try {
- if (input) sessionStorage.setItem(`draft:${chatId}`, input)
- } catch { /* still no room — skip */ }
- }
- // Otherwise private browsing / storage disabled — silently skip.
- }
+ persistComposerDraft(chatId, input)
}, [input, chatId])
// Auto-size textarea when a draft is restored. Cap matches the
@@ -1690,7 +1667,7 @@ export default function ChatView({
if (failedAttempt) {
if (sendAttemptIsDurable(failedAttempt, msgs, data.pending_messages)) {
clearFailedAttempt()
- setInput('')
+ setComposerInput('')
clearFiles()
setSendFailure(null)
} else {
@@ -2021,7 +1998,7 @@ export default function ChatView({
const queuedPinIntent = sendPinIntent
rememberQueuedPinIntent(cid, queuedPinIntent)
inlineSteerPinIntentRef.current = queuedPinIntent
- setInput('')
+ setComposerInput('')
clearComposerFilesForSend()
if (inputRef.current) {
inputRef.current.style.height = 'auto'
@@ -2221,7 +2198,7 @@ export default function ChatView({
const userMsg = { role: 'user', content: text, ts: Date.now(), cid, optimistic: true }
if (attachments.length > 0) userMsg.attachments = attachments
commitMessages(prev => [...prev, userMsg])
- setInput('')
+ setComposerInput('')
clearComposerFilesForSend()
if (inputRef.current) {
inputRef.current.style.height = 'auto'
diff --git a/frontend/src/components/ChatView/__tests__/composerDraft.test.js b/frontend/src/components/ChatView/__tests__/composerDraft.test.js
new file mode 100644
index 00000000..f75c81af
--- /dev/null
+++ b/frontend/src/components/ChatView/__tests__/composerDraft.test.js
@@ -0,0 +1,56 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+
+import { persistComposerDraft } from '../composerDraft.js'
+
+function storageStub(initial = {}) {
+ const values = new Map(Object.entries(initial))
+ return {
+ get length() { return values.size },
+ key(index) { return [...values.keys()][index] ?? null },
+ getItem(key) { return values.get(key) ?? null },
+ setItem(key, value) { values.set(key, String(value)) },
+ removeItem(key) { values.delete(key) },
+ }
+}
+
+test('persists and clears a chat draft synchronously', () => {
+ const storage = storageStub()
+ assert.equal(persistComposerDraft('chat-a', 'unfinished thought', storage), true)
+ assert.equal(storage.getItem('draft:chat-a'), 'unfinished thought')
+
+ assert.equal(persistComposerDraft('chat-a', '', storage), true)
+ assert.equal(storage.getItem('draft:chat-a'), null)
+})
+
+test('evicts one draft and retries when session storage is full', () => {
+ const storage = storageStub({ 'draft:chat-a': 'older' })
+ const normalSet = storage.setItem.bind(storage)
+ let first = true
+ storage.setItem = (key, value) => {
+ if (first) {
+ first = false
+ const error = new Error('full')
+ error.name = 'QuotaExceededError'
+ throw error
+ }
+ normalSet(key, value)
+ }
+
+ assert.equal(persistComposerDraft('chat-b', 'latest', storage), true)
+ assert.equal(storage.getItem('draft:chat-a'), null)
+ assert.equal(storage.getItem('draft:chat-b'), 'latest')
+})
+
+test('the composer state boundary saves before scheduling React state', () => {
+ const source = readFileSync(new URL('../ChatView.jsx', import.meta.url), 'utf8')
+ const start = source.indexOf('function setComposerInput(nextInput)')
+ const end = source.indexOf('\n }', start)
+ const body = source.slice(start, end)
+
+ const save = body.indexOf('persistComposerDraft(chatId, nextInput)')
+ const render = body.indexOf('setInputState(nextInput)')
+ assert.ok(save >= 0, 'all composer changes must be persisted directly')
+ assert.ok(render > save, 'draft persistence must happen before navigation can unmount React')
+})
diff --git a/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js b/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js
new file mode 100644
index 00000000..d08ec311
--- /dev/null
+++ b/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js
@@ -0,0 +1,143 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { preserveTogglePosition } from '../preserveTogglePosition.js'
+
+test('toggle position is corrected from the DOM mutation before rAF', () => {
+ const originalMutationObserver = globalThis.MutationObserver
+ const originalRaf = globalThis.requestAnimationFrame
+ let mutationCallback = null
+ let rafCallback = null
+ let observedNode = null
+ let disconnected = false
+
+ globalThis.MutationObserver = class {
+ constructor(callback) { mutationCallback = callback }
+ observe(node) { observedNode = node }
+ disconnect() { disconnected = true }
+ }
+ globalThis.requestAnimationFrame = (callback) => {
+ rafCallback = callback
+ return 1
+ }
+
+ try {
+ const parentElement = {}
+ const scroller = { scrollTop: 40 }
+ let top = 120
+ const anchor = {
+ parentElement,
+ closest: () => scroller,
+ getBoundingClientRect: () => ({ top }),
+ }
+
+ preserveTogglePosition(anchor)
+ assert.equal(observedNode, parentElement)
+
+ top = 155
+ mutationCallback()
+ assert.equal(scroller.scrollTop, 75)
+ assert.equal(disconnected, true)
+
+ // The scheduled fallback must not apply the correction a second time.
+ rafCallback()
+ assert.equal(scroller.scrollTop, 75)
+ }
+ finally {
+ globalThis.MutationObserver = originalMutationObserver
+ globalThis.requestAnimationFrame = originalRaf
+ }
+})
+
+test('rAF remains a fallback when MutationObserver is unavailable', () => {
+ const originalMutationObserver = globalThis.MutationObserver
+ const originalRaf = globalThis.requestAnimationFrame
+ let rafCallback = null
+
+ globalThis.MutationObserver = undefined
+ globalThis.requestAnimationFrame = (callback) => {
+ rafCallback = callback
+ return 1
+ }
+
+ try {
+ const scroller = { scrollTop: 20 }
+ let top = 80
+ const anchor = {
+ parentElement: {},
+ closest: () => scroller,
+ getBoundingClientRect: () => ({ top }),
+ }
+
+ preserveTogglePosition(anchor)
+ top = 68
+ rafCallback()
+ assert.equal(scroller.scrollTop, 8)
+ }
+ finally {
+ globalThis.MutationObserver = originalMutationObserver
+ globalThis.requestAnimationFrame = originalRaf
+ }
+})
+
+test('closing a disclosure primes the bottom spacer before React removes its body', () => {
+ const originalMutationObserver = globalThis.MutationObserver
+ const originalRaf = globalThis.requestAnimationFrame
+ const originalGetComputedStyle = globalThis.getComputedStyle
+ globalThis.MutationObserver = undefined
+ globalThis.requestAnimationFrame = () => 1
+ globalThis.getComputedStyle = () => ({ marginTop: '4px', marginBottom: '2px' })
+
+ try {
+ const spacer = { offsetHeight: 100, style: {} }
+ const scroller = {
+ scrollTop: 50,
+ querySelector: selector => selector === '.spacer-dynamic' ? spacer : null,
+ }
+ const body = { getBoundingClientRect: () => ({ height: 60 }) }
+ const anchor = {
+ parentElement: {},
+ nextElementSibling: body,
+ closest: () => scroller,
+ getAttribute: name => name === 'aria-expanded' ? 'true' : null,
+ getBoundingClientRect: () => ({ top: 120 }),
+ }
+
+ preserveTogglePosition(anchor)
+ assert.equal(spacer.style.height, '166px')
+ }
+ finally {
+ globalThis.MutationObserver = originalMutationObserver
+ globalThis.requestAnimationFrame = originalRaf
+ globalThis.getComputedStyle = originalGetComputedStyle
+ }
+})
+
+test('opening a disclosure leaves normal spacer sizing alone', () => {
+ const originalMutationObserver = globalThis.MutationObserver
+ const originalRaf = globalThis.requestAnimationFrame
+ globalThis.MutationObserver = undefined
+ globalThis.requestAnimationFrame = () => 1
+
+ try {
+ const spacer = { offsetHeight: 100, style: {} }
+ const scroller = {
+ scrollTop: 50,
+ querySelector: () => spacer,
+ }
+ const anchor = {
+ parentElement: {},
+ nextElementSibling: null,
+ closest: () => scroller,
+ getAttribute: () => 'false',
+ getBoundingClientRect: () => ({ top: 120 }),
+ }
+
+ preserveTogglePosition(anchor)
+ assert.equal(spacer.style.height, undefined)
+ }
+ finally {
+ globalThis.MutationObserver = originalMutationObserver
+ globalThis.requestAnimationFrame = originalRaf
+ }
+})
diff --git a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js
index 8b68f631..bde8d123 100644
--- a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js
+++ b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js
@@ -177,6 +177,22 @@ test('only scrolling keys claim reader ownership', () => {
assert.equal(readerInputMayScroll('touchmove'), true)
})
+test('a disclosure tap does not mistake its collapse clamp for a reader swipe', () => {
+ const disclosureTarget = {
+ closest(selector) {
+ assert.match(selector, /chat__activity-header/)
+ return { className: 'chat__activity-header' }
+ },
+ }
+ const ordinaryTarget = { closest: () => null }
+
+ assert.equal(readerInputMayScroll('pointerdown', '', disclosureTarget), false)
+ assert.equal(readerInputMayScroll('touchstart', '', disclosureTarget), false)
+ assert.equal(readerInputMayScroll('touchmove', '', disclosureTarget), true,
+ 'a real drag starting on the disclosure still claims reader ownership')
+ assert.equal(readerInputMayScroll('pointerdown', '', ordinaryTarget), true)
+})
+
test('inputs without an end event get a next-frame no-scroll release', () => {
assert.equal(readerInputNeedsFrameRelease('wheel'), true)
assert.equal(readerInputNeedsFrameRelease('keydown'), true)
diff --git a/frontend/src/components/ChatView/composerDraft.js b/frontend/src/components/ChatView/composerDraft.js
new file mode 100644
index 00000000..8ff75788
--- /dev/null
+++ b/frontend/src/components/ChatView/composerDraft.js
@@ -0,0 +1,50 @@
+function availableStorage(storage) {
+ if (storage !== undefined) return storage
+ try { return globalThis.sessionStorage ?? null } catch { return null }
+}
+
+function evictOneDraft(storage) {
+ try {
+ const draftKeys = []
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i)
+ if (key?.startsWith('draft:')) draftKeys.push(key)
+ }
+ if (draftKeys.length === 0) return
+ // Chat ids may be integers or UUIDs. Stable lexical order is sufficient:
+ // quota recovery only needs to free one older best-effort draft.
+ draftKeys.sort()
+ storage.removeItem(draftKeys[0])
+ } catch { /* best-effort storage */ }
+}
+
+/**
+ * Persist a composer value immediately.
+ *
+ * This deliberately belongs on the input event path rather than only in a
+ * React effect. A browser back gesture can commit navigation and unmount the
+ * chat before passive effects run, especially while the mobile keyboard is
+ * settling. Synchronous storage here makes the text durable before React gets
+ * a chance to remove the composer.
+ */
+export function persistComposerDraft(chatId, input, storage) {
+ const target = availableStorage(storage)
+ if (!target || chatId == null) return false
+ const key = `draft:${chatId}`
+
+ try {
+ if (input) target.setItem(key, input)
+ else target.removeItem(key)
+ return true
+ } catch (error) {
+ if (error?.name !== 'QuotaExceededError' && error?.code !== 22) return false
+ evictOneDraft(target)
+ try {
+ if (input) target.setItem(key, input)
+ else target.removeItem(key)
+ return true
+ } catch {
+ return false
+ }
+ }
+}
diff --git a/frontend/src/components/ChatView/preserveTogglePosition.js b/frontend/src/components/ChatView/preserveTogglePosition.js
index cb979379..83d66a28 100644
--- a/frontend/src/components/ChatView/preserveTogglePosition.js
+++ b/frontend/src/components/ChatView/preserveTogglePosition.js
@@ -2,10 +2,55 @@ export function preserveTogglePosition(anchorEl) {
if (!anchorEl || typeof requestAnimationFrame !== 'function') return
const scroller = anchorEl.closest?.('.chat__scroll')
if (!scroller) return
+
+ // Closing a disclosure at the physical tail removes height before the chat's
+ // ResizeObserver can grow its dynamic bottom reservation. The browser clamps
+ // scrollTop in that gap, paints the header lower for one frame, then the
+ // observer restores it — the visible down/up twitch. Reserve the body's exact
+ // outer height synchronously so total scroll height stays constant across the
+ // React commit; the observer replaces this provisional value with its normal
+ // spacer calculation on the next layout pass.
+ if (anchorEl.getAttribute?.('aria-expanded') === 'true') {
+ const spacer = scroller.querySelector?.('.spacer-dynamic')
+ const body = anchorEl.nextElementSibling
+ if (spacer && body) {
+ const rectHeight = body.getBoundingClientRect?.().height || 0
+ const styles = typeof getComputedStyle === 'function'
+ ? getComputedStyle(body)
+ : null
+ const marginTop = Number.parseFloat(styles?.marginTop) || 0
+ const marginBottom = Number.parseFloat(styles?.marginBottom) || 0
+ const removedHeight = rectHeight + marginTop + marginBottom
+ if (removedHeight > 0) {
+ spacer.style.height = `${(spacer.offsetHeight || 0) + removedHeight}px`
+ }
+ }
+ }
+
const before = anchorEl.getBoundingClientRect().top
- requestAnimationFrame(() => {
+
+ // A disclosure inserts/removes its body during React's click commit. Observe
+ // that DOM mutation so the scroll correction lands in the same frame, before
+ // the browser paints the collapsed layout. The old rAF-only correction let
+ // one intermediate frame escape first: the page visibly moved with the
+ // collapse, then moved back when rAF adjusted scrollTop.
+ let settled = false
+ let observer = null
+ const settle = () => {
+ if (settled) return
+ settled = true
+ observer?.disconnect()
const after = anchorEl.getBoundingClientRect().top
const delta = after - before
if (Math.abs(delta) > 0.5) scroller.scrollTop += delta
- })
+ }
+
+ if (typeof MutationObserver === 'function' && anchorEl.parentElement) {
+ observer = new MutationObserver(settle)
+ observer.observe(anchorEl.parentElement, { childList: true, subtree: true })
+ }
+
+ // Safety fallback for an unusual disclosure that changes layout without a
+ // child-list mutation. When the observer fired, this becomes a cheap no-op.
+ requestAnimationFrame(settle)
}
diff --git a/frontend/src/components/ChatView/useScrollMode.js b/frontend/src/components/ChatView/useScrollMode.js
index a67892fe..9d3d7d48 100644
--- a/frontend/src/components/ChatView/useScrollMode.js
+++ b/frontend/src/components/ChatView/useScrollMode.js
@@ -534,10 +534,21 @@ export function gestureLayoutRetryDelay(gestureWindowUntil, now) {
}
-/** Only keys whose default action can move the chat begin reader ownership.
+/** Only input whose default action can move the chat begins reader ownership.
* Text entry and activating controls inside a message must not freeze layout
- * until the no-scroll dead-man expires. */
-export function readerInputMayScroll(type, key = '') {
+ * until the no-scroll dead-man expires. A disclosure tap is especially
+ * important here: collapsing its body can clamp scrollTop and emit a synthetic
+ * scroll event. Treating that clamp as the start of a swipe holds the smaller
+ * spacer for the 250ms momentum window, so the viewport moves once on collapse
+ * and again when layout ownership returns. A real drag that starts on a
+ * disclosure still produces touchmove, which claims reader ownership below. */
+export function readerInputMayScroll(type, key = '', target = null) {
+ if ((type === 'pointerdown' || type === 'touchstart')
+ && target?.closest?.(
+ '.chat__activity-header, .chat__tool-header, .chat__marker-header',
+ )) {
+ return false
+ }
if (type !== 'keydown') return true
return [
'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', 'Tab', ' ',
@@ -1430,7 +1441,7 @@ export default function useScrollMode({
})
}
const onUserInput = (event) => {
- if (!readerInputMayScroll(event?.type, event?.key)) return
+ if (!readerInputMayScroll(event?.type, event?.key, event?.target)) return
// Input and its first scroll event are ordered, but not guaranteed to be
// less than 250ms apart under a busy renderer. Keep layout ownership
// suspended until that first event actually lands; after it, the normal
diff --git a/frontend/src/components/Drawer/Drawer.css b/frontend/src/components/Drawer/Drawer.css
index 4a84f20d..e8f84f6f 100644
--- a/frontend/src/components/Drawer/Drawer.css
+++ b/frontend/src/components/Drawer/Drawer.css
@@ -55,6 +55,12 @@
read as part of the same design system. --cubic-move is the
ease curve the SDK uses for sliding panels. */
transition: transform var(--transition-duration-basic, 250ms) var(--cubic-move, cubic-bezier(0.4, 0, 0.2, 1));
+ /* Vertical gestures belong to the drawer list; horizontal gestures are
+ reserved for Drawer.jsx's swipe-to-close interaction. Keeping this modal
+ boundary explicit also stops a pan begun on non-scrollable drawer chrome
+ (a section label or gutter) from chaining into the app iframe beneath it. */
+ touch-action: pan-y pinch-zoom;
+ overscroll-behavior: contain;
}
/* Give longer chat and app names more room on larger screens without
diff --git a/frontend/src/components/Drawer/Drawer.jsx b/frontend/src/components/Drawer/Drawer.jsx
index 0fd3c865..7526bb51 100644
--- a/frontend/src/components/Drawer/Drawer.jsx
+++ b/frontend/src/components/Drawer/Drawer.jsx
@@ -273,7 +273,11 @@ export default function Drawer({
// without easing.
const drawerRef = useRef(null)
const dragStart = useRef(null) // { x, y } or null
- // True once a touch gesture has moved past the tap/swipe threshold.
+ // True once a touch gesture has become a HORIZONTAL swipe. Vertical movement
+ // belongs to the drawer's scroll containers and must never arm click
+ // suppression: some mobile browsers do not emit a synthetic click after a
+ // scroll, so the old "any movement" rule left the suppressor waiting and ate
+ // the user's next real tap on a chat/app row.
// React's onTouch* handlers are passive, so preventDefault() in
// onTouchMove is a no-op and a horizontal drag still emits a synthetic
// click that lands on whatever row the finger lifted over — selecting
@@ -331,13 +335,15 @@ export default function Drawer({
if (!dragStart.current || e.touches.length !== 1) return
const dx = e.touches[0].clientX - dragStart.current.x
const dy = e.touches[0].clientY - dragStart.current.y
- // Any movement past the threshold (in either axis) means this is a
- // drag, not a tap — mark it so touchend can suppress the trailing
- // click even on a scroll/vertical pan that lifted over a row.
- if (Math.abs(dx) > SWIPE_THRESHOLD || Math.abs(dy) > SWIPE_THRESHOLD) {
+ const isHorizontalSwipe = Math.abs(dx) > SWIPE_THRESHOLD
+ && Math.abs(dx) > Math.abs(dy) * 1.15
+ // Only custom horizontal gestures need the one-shot click suppressor.
+ // Native vertical scrolling already owns its tap/click cancellation; arming
+ // our suppressor for it made a quick post-scroll destination tap look dead.
+ if (isHorizontalSwipe) {
swipingRef.current = true
}
- if (dx < 0 && Math.abs(dx) > Math.abs(dy) * 1.15) {
+ if (dx < 0 && isHorizontalSwipe) {
const el = drawerRef.current
if (!el) return
el.classList.add('drawer--dragging')
diff --git a/frontend/src/components/Shell/Shell.css b/frontend/src/components/Shell/Shell.css
index f9c6b3f0..2a7b44da 100644
--- a/frontend/src/components/Shell/Shell.css
+++ b/frontend/src/components/Shell/Shell.css
@@ -152,6 +152,18 @@
z-index: 1;
}
+/* `inert` is the semantic/modal contract, but mobile engines have historically
+ differed on whether an inert ancestor fully removes a cross-origin iframe
+ from native touch hit-testing. Make the physical contract explicit too: an
+ open drawer cannot scroll or activate the app canvas behind it. The drawer
+ and scrim are siblings above this content, so their own gestures are
+ unaffected. */
+.shell__content[inert] {
+ pointer-events: none;
+ touch-action: none;
+ overscroll-behavior: none;
+}
+
/* `.shell__view` wraps each persisted view (ChatView, AppCanvas).
All views are laid out simultaneously (position: absolute, inset
0) and toggled via `visibility` + `pointer-events`. Why not
diff --git a/frontend/src/lib/__tests__/serviceSurfaceControls.test.js b/frontend/src/lib/__tests__/serviceSurfaceControls.test.js
index 3e1dddd7..f081dfc4 100644
--- a/frontend/src/lib/__tests__/serviceSurfaceControls.test.js
+++ b/frontend/src/lib/__tests__/serviceSurfaceControls.test.js
@@ -14,3 +14,13 @@ test('service failure controls remain clickable inside the click-through loading
assert.match(loading, /pointer-events:\s*none/)
assert.match(offline, /pointer-events:\s*auto/)
})
+
+test('service failure actions render as separated touch-sized buttons', () => {
+ const actions = css.match(/\.canvas-loading__offline-actions\s*\{([^}]*)\}/)?.[1] || ''
+ const button = css.match(/\.canvas-loading__offline-button\s*\{([^}]*)\}/)?.[1] || ''
+
+ assert.match(actions, /display:\s*flex/)
+ assert.match(actions, /flex-wrap:\s*wrap/)
+ assert.match(actions, /gap:\s*10px/)
+ assert.match(button, /min-height:\s*44px/)
+})
diff --git a/tests/navigation.spec.mjs b/tests/navigation.spec.mjs
index 6b5b95a0..127879af 100644
--- a/tests/navigation.spec.mjs
+++ b/tests/navigation.spec.mjs
@@ -726,15 +726,72 @@ test.describe('Drawer close paths converge through handleBack', () => {
test('22b. Drawer scrim owns touch pans instead of the background', async ({ page }) => {
await setup(page)
await openDrawer(page)
- const contract = await page.locator('.drawer-overlay').evaluate((overlay) => ({
- touchAction: getComputedStyle(overlay).touchAction,
- overscrollBehavior: getComputedStyle(overlay).overscrollBehavior,
- }))
- expect(contract.touchAction).toBe('none')
- expect(contract.overscrollBehavior).toBe('none')
+ const contract = await page.evaluate(() => {
+ const overlay = getComputedStyle(document.querySelector('.drawer-overlay'))
+ const drawer = getComputedStyle(document.querySelector('.drawer'))
+ const content = getComputedStyle(document.querySelector('.shell__content'))
+ return {
+ overlayTouchAction: overlay.touchAction,
+ overlayOverscroll: overlay.overscrollBehavior,
+ drawerTouchAction: drawer.touchAction,
+ drawerOverscroll: drawer.overscrollBehavior,
+ contentPointerEvents: content.pointerEvents,
+ contentTouchAction: content.touchAction,
+ }
+ })
+ expect(contract).toEqual({
+ overlayTouchAction: 'none',
+ overlayOverscroll: 'none',
+ drawerTouchAction: 'pan-y pinch-zoom',
+ drawerOverscroll: 'contain',
+ contentPointerEvents: 'none',
+ contentTouchAction: 'none',
+ })
+ })
+
+ test('22c. Vertical drawer scroll does not swallow the next destination tap', async ({ page }) => {
+ await setup(page, { width: 426, height: 860 })
+ await openDrawer(page)
+
+ // Reproduce the phone sequence without waiting out the old suppressor's
+ // 400ms fallback: scroll vertically, lift, then immediately tap Settings.
+ // A vertical pan is native scrolling, not the custom horizontal
+ // swipe-to-close gesture, so the destination click must pass through.
+ await page.locator('.drawer').evaluate((drawer) => {
+ const point = (y) => new Touch({
+ identifier: 37,
+ target: drawer,
+ clientX: 180,
+ clientY: y,
+ })
+ drawer.dispatchEvent(new TouchEvent('touchstart', {
+ bubbles: true,
+ cancelable: true,
+ touches: [point(520)],
+ }))
+ drawer.dispatchEvent(new TouchEvent('touchmove', {
+ bubbles: true,
+ cancelable: true,
+ touches: [point(390)],
+ }))
+ drawer.dispatchEvent(new TouchEvent('touchend', {
+ bubbles: true,
+ cancelable: true,
+ touches: [],
+ changedTouches: [point(390)],
+ }))
+
+ const settings = [...drawer.querySelectorAll('button')]
+ .find(button => button.textContent.trim() === 'Settings')
+ settings?.click()
+ })
+
+ await expect(page.locator('.settings')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Toggle navigation' }))
+ .toHaveAttribute('aria-expanded', 'false')
})
- test('22c. Interrupted drawer swipe cannot strand an inert panel onscreen', async ({ page }) => {
+ test('22d. Interrupted drawer swipe cannot strand an inert panel onscreen', async ({ page }) => {
await setup(page, { width: 426, height: 860 })
await openDrawer(page)
@@ -776,7 +833,7 @@ test.describe('Drawer close paths converge through handleBack', () => {
)).toBeLessThanOrEqual(-359)
})
- test('22d. Closing scrim blocks the app until the panel is offscreen', async ({ page }) => {
+ test('22e. Closing scrim blocks the app until the panel is offscreen', async ({ page }) => {
await setup(page, { width: 426, height: 860 })
await openDrawer(page)
await page.locator('.drawer-overlay').dispatchEvent('pointerdown', {
diff --git a/tests/service-surface.spec.mjs b/tests/service-surface.spec.mjs
index 1b6f51ae..26d35d27 100644
--- a/tests/service-surface.spec.mjs
+++ b/tests/service-surface.spec.mjs
@@ -201,7 +201,13 @@ test('shared gateway stays branded until heartbeat, preserves cookies, and rejec
expect(cspSources(shellResponse.headers()['content-security-policy'], 'frame-src'))
.toEqual(["'self'", SERVICE_ORIGIN])
const appFrame = page.locator(`iframe[src*="/api/apps/${app.id}/frame"]`)
- await expect(appFrame).toHaveCount(1, { timeout: 5_000 })
+ // This is a cold deep-link immediately after creating the app. On a clean
+ // browser the shell may also complete its first service-worker handoff
+ // before the catalog hydrates; five seconds proved too tight under a busy
+ // CI runner even though the frame appeared and the same contract passed on
+ // the PR head. Keep this bounded, but align it with the shell's normal
+ // cold-start budget rather than treating scheduler latency as a failure.
+ await expect(appFrame).toHaveCount(1, { timeout: 15_000 })
const appSandbox = (await appFrame.getAttribute('sandbox') || '').split(/\s+/)
expect(appSandbox).not.toContain('allow-same-origin')
// load fires even for the deliberately blocked document. While the shell