diff --git a/docs/systems/design-system.md b/docs/systems/design-system.md
index 6518dc73..04e49066 100644
--- a/docs/systems/design-system.md
+++ b/docs/systems/design-system.md
@@ -53,7 +53,7 @@ Font: DM Sans (primary) with system fallbacks
|------|-------|-------------|
| Structural | `bg-surface-*` | Permanent layout (sidebars, chat, member list) |
| Strip | `.glass-strip` | Persistent edge chrome (space sidebar) |
-| Bubble | `.glass-bubble` | Persistent floating controls (voice bar, input pill) |
+| Bubble | `.glass-bubble` | Persistent floating controls (voice bar, **chat composer**, voice mini-bar). Chat composer is a floating bubble on **both** desktop and mobile (`position: absolute`, sits above the message-list scroll area; messages scroll behind it). On mobile, its `bottom` value is driven by `useVisualViewportInset()` so it lifts above the iOS soft keyboard when one is open. See `docs/systems/mobile-ui.md` "Floating Composer" for the full pattern. |
| Popover | `.glass` | Small floating surfaces (context menus, popovers, tooltips) |
| Tray dropdown | `.glass` (popover) | `TransferIndicator` global panel — anchored under the channel-header icon. |
| Modal | `.glass-modal` | Large center-screen dialogs |
diff --git a/docs/systems/message-list.md b/docs/systems/message-list.md
index 6b420cb2..b0842877 100644
--- a/docs/systems/message-list.md
+++ b/docs/systems/message-list.md
@@ -11,6 +11,12 @@ The chat message list (`packages/web/src/components/chat/MessageList.tsx`) is re
| `packages/web/src/components/chat/embeds/*.tsx` | Embed renderers — must obey the dimension reservation contract below. |
| `packages/web/src/components/chat/AttachmentRenderer.tsx` | Reference for the dimension reservation pattern (`AttachmentRenderer.tsx:81-100`). |
+## Bottom clearance — `--composer-clearance`
+
+The MessageList content's `paddingBottom` is dynamic — it reads the CSS variable `var(--composer-clearance, 80px)` written to the chat region's wrapper element by `MessageInput` via a `ResizeObserver`. The variable's value is `composer.height + composer.bottom-offset + 12 px` so the last message always lands 12 px above the composer's top edge regardless of (a) composer height (reply banner, staged-attachment tile row, multi-line autosize), (b) the composer's own `bottom` style (desktop 12 px / mobile-keyboard-closed `env(safe-area-inset-bottom) + 6` / mobile-keyboard-open 0).
+
+The 80 px fallback is sized for the desktop case during the brief mount window before the first ResizeObserver tick — it matches the previous static `pb-20`. See `docs/systems/mobile-ui.md` "Floating Composer" for the full rationale; the short version is that any static value is wrong on iPhone (composer height + safe-area + 6 already exceeds 80 px) and wrong when the composer grows (replies, attachments).
+
## ContainerRef invariant
`containerRef.current` (and `contentRef.current`) MUST be non-null for the entire lifetime of any chat view where messages may arrive or scroll-affecting effects can run. Every effect in this file (initial snap A, ResizeObserver B, capture-phase load handler C, scrollend final-pin D, jump-to-message) reads these refs and bails on a null guard. Each of those effects is keyed on `[messages.length, channelId]` or `[hasMessages, channelId]`, and the inbound transition (`0 → N` / `false → true`) is the *only* re-fire signal during a channel-load lifecycle. If a ref is null at the moment that signal fires, the effect bails — and no later dep change will retry it, leaving the channel permanently broken (initial scroll never lands at bottom, ResizeObserver never observes, scrollend never registers, saved-anchor restore never happens).
diff --git a/docs/systems/mobile-ui.md b/docs/systems/mobile-ui.md
index 0c496d8e..e5682dc5 100644
--- a/docs/systems/mobile-ui.md
+++ b/docs/systems/mobile-ui.md
@@ -17,6 +17,8 @@ Source files:
- `packages/web/src/components/layout/MobileVoiceMiniBar.tsx` — Persistent mini-bar overlay during voice calls
- `packages/web/src/components/layout/MobileFolderSheet.tsx` — Bottom sheet for space folder contents, rename, color, ungroup
- `packages/web/src/hooks/useSwipeGesture.ts` — Edge swipe-back touch gesture hook
+- `packages/web/src/hooks/useDragToClose.ts` — Bottom-sheet drag-down-to-dismiss gesture hook (shared by `InputPopover.MobileSheet`, `MobileVoiceJoinSheet`, `MobileFolderSheet`)
+- `packages/web/src/hooks/useVisualViewportInset.ts` — Returns the bottom inset that floating overlays must use to sit above the iOS soft keyboard (when open) or above the home-indicator safe area (when closed). Used by `MessageInput` for the floating composer-bubble's `bottom` value.
- `packages/web/src/stores/uiStore.ts` — Mobile navigation state (mobileScreen, mobileStack, push/pop actions)
Cross-references:
@@ -580,8 +582,147 @@ Several mobile components respect the iOS safe area inset:
| MobileVoiceFullScreen control bar | `marginBottom: calc(0.5rem + env(safe-area-inset-bottom))` |
| MobileFolderSheet | `paddingBottom: env(safe-area-inset-bottom)` |
| MobileSpacesScreen add sheet | `paddingBottom: env(safe-area-inset-bottom)` |
+| MessageInput | `bottom: calc(env(safe-area-inset-bottom) + 6px)` (keyboard closed) / `0px` (keyboard open) — see "Floating Composer" below |
-The root MobileShell uses `height: 100dvh` (dynamic viewport height) to account for mobile browser chrome.
+The root `MobileShell` normally uses `height: 100dvh` (dynamic viewport height) to account for mobile browser chrome. **When the iOS soft keyboard is open** (detected via `useVisualViewportInset().keyboardOpen`), the shell switches to `height: ${visualViewport.height}px` so the visible region of the shell is exactly the area above the keyboard. This is the load-bearing mechanism for the floating composer landing flush against the keyboard top — see "Floating Composer" below.
+
+---
+
+## Floating Composer (Chat MessageInput)
+
+The chat composer (`MessageInput.tsx`) is a `glass-bubble` floating overlay on **both** desktop and mobile — there is no flow-positioned mobile branch. The pattern is shared so feature parity is automatic.
+
+### Layout model
+
+```
+┌──────────────────────────────────────────────┐
+│ MobileChatScreen / MainContent │ ← `relative flex flex-col`
+│ ┌────────────────────────────────────────┐ │
+│ │ │ │ ← fills the chat region
+│ │ (scrolls; content's `paddingBottom` │ │
+│ │ = `var(--composer-clearance, 80px)` │ │
+│ │ so the last message always clears │ │
+│ │ the bubble with a 12px breathing gap)│ │
+│ └────────────────────────────────────────┘ │
+│ ┌────────────────────────────────────────┐ │
+│ │ │ │ ← `position: absolute`
+│ │ glass-bubble, translucent │ │ `left-2 right-2 z-[110]`
+│ │ bottom = 0 (kbd open) / safe+6 (kbd │ │ (mobile) / `md:left-3 md:right-3 md:bottom-3` (desktop)
+│ │ closed). Writes --composer-clearance │ │
+│ │ on its parent via ResizeObserver. │ │
+│ └────────────────────────────────────────┘ │
+└──────────────────────────────────────────────┘
+```
+
+The MessageList scroll region fills the entire chat area. The composer is `position: absolute` and overlays the bottom; messages scroll *behind* the translucent bubble. **Containers must declare `position: relative`** for the composer's absolute positioning to resolve correctly — `MainContent` does this on its outermost flex column; `MobileChatScreen` does this on the inner messages-and-composer wrapper.
+
+### Last-message clearance — the `--composer-clearance` CSS variable
+
+The MessageList content's `paddingBottom` is **dynamic**, driven by a CSS variable named `--composer-clearance` written to the chat region's wrapper element by `MessageInput` via a `ResizeObserver`:
+
+```
+--composer-clearance = composer.height + composer.bottom-offset + 12 px
+```
+
+Where `bottom-offset` is the gap between the wrapper's bottom edge and the composer's bottom edge (i.e. the resolved value of the composer's `bottom` style — `12 px` on desktop, `env(safe-area-inset-bottom) + 6` ≈ `40 px` on iPhone with keyboard closed, `0` on mobile with keyboard open). The `+12 px` constant is the desired breathing-room gap between the last message's bottom edge and the composer's top edge.
+
+`MessageList` reads `var(--composer-clearance, 80px)` as `paddingBottom`. The `80px` fallback covers the brief mount window before the first measurement, plus any future surface that mounts a `MessageList` without a sibling `MessageInput`.
+
+**Why dynamic?** The previous static `pb-20` (80 px) was sized for the desktop case (composer ≈ 50 px tall + 12 px bottom = 62 px, leaving 18 px of gap). On iPhone with the keyboard closed, the composer's bottom-offset is `env(safe-area-inset-bottom) + 6` ≈ 40 px, so `composer-height + bottom-offset` ≈ `44 + 40` = `84 px` — already exceeding the 80 px `pb-20`, with **negative** breathing room. The composer also grows when the user replies to a message (banner adds 36 px) or stages attachments (tile row adds 184 px), so any static value is wrong for some configurations. The ResizeObserver-driven CSS variable is the only correct model.
+
+The variable is scoped to the chat region's wrapper rather than `:root` so future multi-pane layouts (e.g. side-by-side DM list + chat, voice chat side-panel) don't cross-talk; a wrapper-scoped variable inherits naturally to its `MessageList` descendant.
+
+### `useVisualViewportInset()` — keyboard-aware geometry
+
+iOS Safari's `env(safe-area-inset-bottom)` is defined relative to the **layout** viewport (full screen), not the **visual** viewport (the visible region above the soft keyboard). When the iOS soft keyboard slides up, the layout viewport stays the same height and `safe-area-inset-bottom` still reports ~34 px (the home-indicator inset). A composer pinned to `bottom: env(safe-area-inset-bottom) + 6 px` therefore ends up `~40 px` above the layout-bottom, which on iPhone 14 Pro is `300+ px` above the keyboard — there is a huge empty gap between the composer and the keyboard top.
+
+The hook subscribes to `window.visualViewport.resize` / `scroll` and computes:
+
+```
+keyboardOcclusion = window.innerHeight - (visualViewport.offsetTop + visualViewport.height)
+```
+
+It returns `{ value, keyboardOpen, height, offsetTop }`:
+- `value` — `'px'` when the keyboard is open (the occlusion), or the literal `'env(safe-area-inset-bottom)'` string when it is not. Provided for legacy / fallback use.
+- `keyboardOpen` — `true` when `keyboardOcclusion > 1`.
+- `height` — live `visualViewport.height` in pixels (or `null` if `visualViewport` is unavailable).
+- `offsetTop` — live `visualViewport.offsetTop` in pixels.
+
+#### iOS PWA standalone — the load-bearing mechanism
+
+`MobileShell` consumes `{ keyboardOpen, height }` and sets its own `style.height` to `${vv.height}px` whenever the keyboard is open. The chat region's `bottom` edge is therefore exactly the keyboard's top edge, and `` lands flush. **This is the primary mechanism, not the inset arithmetic** — sizing the container is far more robust than arithmetic on a `bottom` value, because the math depends on `vv.resize` events firing reliably (which they do not in iOS standalone PWA on several iOS versions). The composer's `bottom` is a simple binary toggle: `0` when keyboard open, `env(safe-area-inset-bottom) + 6 px` when closed.
+
+To cover the case where `vv.resize` fails to fire on iOS PWA (a long-standing standalone-mode bug), the hook **also** listens to `focusin` / `focusout` on `window` for any text-entry element and **polls** `vv.height` at 32 ms intervals for up to 600 ms after the focus change. Polling exits early once the height is stable for two consecutive ticks. This catches the case where iOS silently updates `vv.height` without dispatching a `resize` event — the polling just re-reads the value and re-derives `keyboardOpen`, which then triggers the shell-height update.
+
+When the keyboard is closed, `MobileShell` reverts to `height: 100dvh` so the shell again extends through the home-indicator safe area, and the composer reverts to `bottom: env(safe-area-inset-bottom) + 6 px` so it sits 6 px above the home indicator.
+
+#### Viewport meta hint
+
+`packages/web/index.html`'s viewport `` includes `interactive-widget=resizes-content`. Chrome (Android) honors this by resizing the layout viewport when the soft keyboard opens, which is the cleaner native equivalent of what `MobileShell` does manually. Safari iOS does not honor it, but it's harmless there.
+
+### MessageInput's mobile vs. desktop class split
+
+The component declares one `composerClass` shared by both modes. Differences:
+
+- Mobile inline `style={{ bottom: keyboardOpen ? '0px' : 'calc(env(safe-area-inset-bottom) + 6px)' }}` — applied only when `useUIStore.isMobile === true`. The hook is safe to call on desktop (no-ops), but the `style` is only emitted on mobile so desktop's CSS-driven `md:bottom-3` (12 px) constant is unaffected by the inline override.
+- Tailwind: `absolute left-2 right-2 z-[110] glass-bubble rounded-[14px] md:left-3 md:right-3 md:bottom-3`. The `left-2/right-2` 8 px inset is mobile; `md:left-3/right-3/bottom-3` overrides to 12 px on desktop. `bottom` is intentionally NOT in the Tailwind class on mobile — the inline `style.bottom` provides the dynamic value.
+
+`TypingIndicator` is rendered inside `MessageInput` (anchored `absolute bottom-full` to the bubble) so it appears just above the composer. Mobile chat screens must NOT render an additional `TypingIndicator` themselves.
+
+#### Composer-element ref shape
+
+`MessageInput` tracks the live composer DOM element via a callback ref that fans out to (a) the existing imperative `popoverAnchorRef` (consumed by `InputPopover` and the mention popover for anchor positioning) AND (b) a state-backed `composerEl` slot that drives the `--composer-clearance` ResizeObserver effect. The state-backed slot is required because the component renders different JSX when `canSendMessages` is false (the no-permission early-return path) vs. true (the full composer): a plain `useEffect` keyed only on stable deps would not re-fire when the ref attaches as the JSX flips, leaving the CSS variable unset until the next dep change. Channel permissions arrive asynchronously, so the initial mount renders the no-permission JSX first and re-renders the full composer once permissions resolve — the callback ref's `setComposerEl` call re-fires the effect at that moment.
+
+---
+
+## Bottom-Sheet Drag-to-Close (`useDragToClose`)
+
+File: `packages/web/src/hooks/useDragToClose.ts`
+
+Three hand-rolled bottom sheets share this gesture hook so each surface gets identical iOS-native-feeling dismissal without depending on a third-party gesture library:
+
+| Sheet | File | Drag-handle area |
+|---|---|---|
+| Emoji / GIF picker | `packages/web/src/components/chat/InputPopover.tsx` (`MobileSheet`) | Visible pill + tab bar |
+| Voice-channel join sheet | `packages/web/src/components/voice/MobileVoiceJoinSheet.tsx` | Visible pill + title row |
+| Space-folder sheet | `packages/web/src/components/layout/MobileFolderSheet.tsx` | Visible pill + folder header row |
+
+### Hook contract
+
+```ts
+const { sheetStyle, handleProps, isDragging, isClosing, hasInteracted } = useDragToClose({
+ onClose, // required
+ closeThreshold = 100, // px below resting → commits to close on release
+ velocityThreshold = 0.5, // px/ms downward → commits to close on release
+ closeAnimationMs = 200, // close-out transition duration (matches the open keyframe)
+ enabled = true,
+});
+```
+
+- `sheetStyle` — spread onto the sheet container's inline `style`. While dragging, applies `transform: translateY(px)` with `transition: none` so the sheet follows the finger 1:1. On release, `transition: transform ms cubic-bezier(0.22, 1, 0.36, 1)` engages and the inline `transform` glides smoothly. If the close gate is met, `dragOffset` is **animated from the current offset directly to viewport height** (no intermediate snap-back to 0), and `onClose` fires after the animation completes.
+- `handleProps` — `{ onTouchStart }`. Spread onto the **drag-handle area** (the visible pill + the sheet's header row, never the scrollable content). Scrollable regions and tappable buttons inside the body are unaffected because the document-level `touchmove` / `touchend` listeners are only installed once a drag is in flight.
+- `isDragging` — true between `touchstart` and `touchend`.
+- `isClosing` — true during the close-out animation phase (after the threshold/velocity gate fires, until `onClose` fires).
+- `hasInteracted` — flips `true` on the first touchstart and stays `true` for the lifetime of the consumer mount. **Consumers MUST gate their open-animation classes on `!hasInteracted`** (e.g. `${hasInteracted ? '' : 'animate-slide-up-sheet'}`) so the open keyframe doesn't re-run during snap-back / close-out and fight the inline `transform` driven by `sheetStyle`. Using `isDragging` alone is insufficient — once `isDragging` flips back to false (release), the keyframe re-applies on the next render and the `translateY(100%) → translateY(0)` ramp visually overrides the glide.
+
+### Gesture behaviour
+
+| Phase | Behaviour |
+|---|---|
+| Touch start on handle | Captures finger Y + `performance.now()`. No visible change yet. `hasInteracted` flips to `true`. |
+| Move dy < 6 px | Dead-zone — ignored, native scrolling/pull-to-refresh still possible. |
+| Move dy ≥ 6 px | Commits to drag. `e.preventDefault()` blocks scroll/pull-to-refresh. Sheet follows finger. |
+| Release dy < 100 px and v < 0.5 px/ms | Snap back: `dragOffset → 0` via inline `transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1)`. Sheet stays open. |
+| Release dy ≥ 100 px **or** v > 0.5 px/ms (and dy > 16 px) | Snap close: `isClosing` flips on, transition engages, `dragOffset` ramps from current value to `window.innerHeight` over `closeAnimationMs`, then `onClose()` fires. **No intermediate snap to 0.** |
+| Touch cancel | Snap back to 0 with the same transition (consistent with release-without-commit). |
+
+### Coexistence with other patterns
+
+- **Tap-outside-to-close** stays wired via the existing backdrop `
` — independent code path, unaffected by the gesture hook.
+- **Tap-on-handle** is treated as a no-op (touch starts and ends inside the dead-zone → no offset → no commit). `hasInteracted` does flip true, but with `dragOffset === 0` the inline transform stays at `translateY(0)` and the visible state matches the open state.
+- **iOS pull-to-refresh** is blocked because `touchmove` is non-passive and calls `preventDefault()` once we cross the dead-zone.
+- **Internal scrolling** (e.g. emoji grid, GIF results, folder space list) is **untouched** — `handleProps.onTouchStart` is bound to the header element only, so scroll containers below it never enter drag mode.
+- **Open animation** (`animate-slide-up-sheet` for `MobileFolderSheet` / `InputPopover.MobileSheet`, the `translate-y-full → translate-y-0` flip for `MobileVoiceJoinSheet`) is gated by `!hasInteracted`. After the first touch, the open class never re-applies for the rest of the sheet's lifetime — the inline `transform` + transition becomes the sole animator for both snap-back and close-out.
---
diff --git a/packages/web/index.html b/packages/web/index.html
index 71d407eb..1a29dc55 100644
--- a/packages/web/index.html
+++ b/packages/web/index.html
@@ -3,7 +3,7 @@
-
+
diff --git a/packages/web/src/components/chat/AttachmentRenderer.tsx b/packages/web/src/components/chat/AttachmentRenderer.tsx
index 4f6e6132..56c597fc 100644
--- a/packages/web/src/components/chat/AttachmentRenderer.tsx
+++ b/packages/web/src/components/chat/AttachmentRenderer.tsx
@@ -162,6 +162,16 @@ export function AttachmentRenderer({ attachment }: AttachmentRendererProps) {
);
}
+ // Generic-file chip (PDF / .zip / .exe / unknown mimetypes).
+ //
+ // Width contract: the chip must fit inside the message column on every
+ // viewport. We cap at 400 px on roomy layouts but `max-w-full` keeps it
+ // inside narrow columns (mobile, narrow desktop window, threaded reply
+ // contexts). `min-w-0` is the critical bit on the inner flex children — the
+ // outer button is a flex container with a fixed-size icon and a flexible
+ // text block; without `min-w-0` the long-filename child would refuse to
+ // shrink (flex children's min-content size defaults to their intrinsic
+ // content) and would push the entire chip past the parent's right edge.
return (