Files
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
ci / test (push) Waiting to run
ci / lint-and-format (push) Waiting to run
ci / build (push) Waiting to run
ci / dev-startup (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
gitleaks / gitleaks (push) Waiting to run
Markdown Links / Relative Markdown Links (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Quality (Extended) / Homepage Build (PR smoke) (push) Waiting to run
Quality (Extended) / Comment-only diff guard (push) Waiting to run
Quality (Extended) / Format + Type Safety Ratchet (push) Waiting to run
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Waiting to run
Quality (Extended) / Develop Gate (lint) (push) Waiting to run
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

315 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Chat window UX: scrolling, gestures, search, mic
## Summary
Workstream research for the LifeOps Personal Assistant MVP (GitHub project 15).
Chat is the MVP's primary surface: every LifeOps flow (reminders, goals, todos,
scheduling, coordination) is driven through the continuous chat overlay, so its
scrolling, gesture, search, and voice affordances must be solid for every kind
of user — children, adults with ADHD/ADD/Asperger's/autism, neurotypical
adults, and elderly people — with no therapy language and no special rails,
just a chat window that never fights the hand that drives it.
The audit's headline: **the gesture/detent system asked for in the brief is
already built, well-documented, and CI-gated** (pull-to-maximize landed in
#13531; the maximize button is already gone; detent magnetism, springs, and
haptics all exist). What is actually missing or broken is narrower: **no
history pagination at all** (hard 80-row render cap over a 200-message server
window), **a transcript that can silently become horizontally scrollable**,
**message search that exists end-to-end but is unreachable from the primary
surface**, **a mic button that does not pulse while recording** on the primary
surface, and **a stale Playwright suite still asserting buttons #13531
removed**. The MVP plan is fix-and-verify, not build: six issues, five of
which delete, wire, or repair what already exists.
## Current state
### Render paths (three, not two)
1. **`ContinuousChatOverlay`** — the primary surface, always mounted over every
view ([packages/ui/src/App.tsx:2513](../../../ui/src/App.tsx),
[packages/ui/src/components/shell/ContinuousChatOverlay.tsx:128-157](../../../ui/src/components/shell/ContinuousChatOverlay.tsx)).
One infinite thread since #13531 — no conversation switcher, no clear/new-chat.
2. **`ChatSurface` + `AssistantOverlay` + `HomePill`** — mounted only by
`ShellFoundationMount` for the desktop OS chat-overlay window and the kiosk
shell ([App.tsx:496](../../../ui/src/App.tsx), [App.tsx:539](../../../ui/src/App.tsx)).
3. **`ChatView`** (1,536 lines) — desktop *detached* chat windows only, via
[packages/app-core/src/runtime/desktop/AppWindowRenderer.tsx:44](../../../app-core/src/runtime/desktop/AppWindowRenderer.tsx)
and `DetachedShellRoot.tsx:71`. Not routed anywhere in the web/mobile shell.
Message *text* additionally renders through two independent code paths (overlay
body renderer vs `MessageContent.tsx`), pinned by a render-parity contract test
(`parser-parity.contract.test.ts`, `render-parity.contract.tsx`). Any formatting
change must land in both.
### Sheet state machine, detents, gestures — built and tested
Single source of truth: `ChatMode = "pill" | "input" | "half" | "full"` with
derived `ChatState` (`CLOSED | INPUT | OPEN_UNDER_HALF | OPEN_HALF_OR_OVER |
MAXIMIZED`) — [ContinuousChatOverlay.tsx:177-193](../../../ui/src/components/shell/ContinuousChatOverlay.tsx).
The interaction constants ARE the spec (all verified in code):
| Parameter | Value | Ref (ContinuousChatOverlay.tsx) |
|---|---|---|
| HALF detent | 0.46 × viewport height | `:197` |
| FULL detent | ≈0.9 × viewport (inset, under status bar) | `:198-206` |
| Pull-to-maximize | peak raw pull ≥ max(0.8×vh, FULL) + 56px over-pull | `:207`, `:3228-3237` |
| Restore-from-maximized | downward pull starting in top 20% of panel | `:211`, `:3878-3892` |
| Detent magnet | release within 64px snaps to detent, else free-rest | `:219`, `:3385-3410` |
| Pill→input morph distance | 120px of finger travel; excess flows into thread height | `:265`, `:3158-3183` |
| Sheet spring | stiffness 320 / damping 34 / mass 0.9 | `:247-252` |
| Pill-open spring | stiffness 300 / damping 26 / mass 0.85 | `:256-261` |
| Detent haptic | Capacitor light impact per detent cross | `:228-246` |
The live drag writes the `flexBasis` MotionValue directly (no React re-render
per frame, `:3985-3995`); flicks step one detent; slow drags free-rest with
magnetism; pull-down through input→pill reverses the morph under the finger
(`:3171-3183`). **The maximize button is already removed** — header carries
only a Launcher button (`:3895-3901`), and
`ContinuousChatOverlay.test.tsx:2308-2309` pins `chat-full-maximize` /
`chat-full-clear` absent. Pull-to-maximize + top-20% restore are e2e-covered
with real CDP touch (`__e2e__/run-chat-sheet-e2e.mjs:256-354`).
Gesture engine: `use-pull-gesture.ts` — axis lock at 8px slop, widened
0.8-dominance cone for swipes, rAF-coalesced drag, commit-on-`pointercancel`
for Android (#9943), capture-loss handling for rotation. **Weakness:** its
`verticalScrollPriority` option (`:87-102`, built for the transcript,
#chat-scroll-web) has **zero consumers** — dead code since #13531 removed the
transcript swipe binding (grep: only definition sites match).
### Scrolling — what works and what is missing
Works: the transcript scroller (`#continuous-thread`,
[ContinuousChatOverlay.tsx:4025](../../../ui/src/components/shell/ContinuousChatOverlay.tsx))
is `touch-pan-y overflow-y-auto overscroll-contain` with the iOS
`-webkit-overflow-scrolling:touch` fix and a WebKit-safe bounded-height flex
chain (`:3966-3995`, guarded by `run-chat-scroll-web-e2e.mjs` on Chromium AND
WebKit). Tail-follow / jump-to-latest is the shared `useThreadAutoScroll`
engine ([packages/ui/src/hooks/useThreadAutoScroll.ts:1-23](../../../ui/src/hooks/useThreadAutoScroll.ts),
wired at `:4126`).
**Missing — history pagination (req 1).** `GET
/api/conversations/:id/messages` returns only the newest 200
(`CONVERSATION_MESSAGE_WINDOW`,
[packages/agent/src/api/conversation-routes.ts:1050](../../../agent/src/api/conversation-routes.ts),
`:1684-1688`); there is no `before` cursor — only `?around=<id>` for search
jumps (`:1052-1118`). The client fetches the window as a full replace
([packages/ui/src/state/useDataLoaders.ts:336-368](../../../ui/src/state/useDataLoaders.ts))
and the overlay renders at most the last **80** rows
(`MAX_RENDERED_SHELL_MESSAGES`,
[packages/ui/src/components/shell/shell-state.ts:60](../../../ui/src/components/shell/shell-state.ts)).
Scrolling up simply ends. `runtime.getMemories` already supports
`end`+`limit`+`orderBy desc` (used by the around-window), so a cursor is a
small, store-pushed addition.
**Broken — horizontal-scroll "unlock" (req 1).** Root cause found statically:
the scroller sets `overflow-y-auto` but **not** `overflow-x-hidden` (`:4025`).
Per CSS Overflow, specifying one axis coerces the other from `visible` to
`auto` — so the moment any child overflows horizontally, the transcript itself
becomes a horizontal scroll container. `touch-pan-y` blocks touch panning, but
**trackpad/wheel deltas ignore `touch-action`** — on desktop a diagonal
two-finger scroll pans the thread sideways. Overflowing children exist today:
text/code attachment previews use both-axes `overflow-auto` with no
`overscroll-x-contain` ([MessageAttachments.tsx:831](../../../ui/src/components/chat/MessageAttachments.tsx));
inline widgets are unaudited for min-width. The other chat path already does
this right: `chat-thread-layout.tsx:106-107` pairs `overflow-x-hidden
overflow-y-auto`. Designed horizontal scrollers inside the thread
(code blocks `MessageContent.tsx:572,1242`, chips bar `TopicChipsBar.tsx:30`)
are fine — they carry `overscroll-x-contain`.
### Search (req 2) — exists end-to-end, unreachable on the primary surface
- Server: `GET /api/conversations/messages/search` — SQL `ILIKE` pushdown
scoped to accessible rooms, BM25 ranking, snippets, limit clamp 50
([conversation-routes.ts:1470-1545](../../../agent/src/api/conversation-routes.ts)).
- Client API: `searchConversationMessages()`
([packages/ui/src/api/client-chat.ts:334-337](../../../ui/src/api/client-chat.ts)).
- Jump plumbing: `?around=` window load
(`useDataLoaders.ts:451-481`, #9955).
- UI: `MessageSearchPanel`
([packages/ui/src/components/chat/message-search/MessageSearchPanel.tsx:33](../../../ui/src/components/chat/message-search/MessageSearchPanel.tsx))
— mounted **only** inside `ConversationsSidebar:971`, which is mounted
**only** in the desktop detached shell (`DetachedShellRoot.tsx:157`). On
web/mobile — the MVP surfaces — search is unreachable (an architecture
rule-10 violation: an endpoint without a client trigger on the shipping
surface).
### Mic + '+' buttons (req 5)
`SoftButton` ([ContinuousChatOverlay.tsx:309-370](../../../ui/src/components/shell/ContinuousChatOverlay.tsx)):
borderless 44×44 hit target (WCAG 2.5.5), lucide icons at 26px, hand-drawn
glyphs at 30px — the icons were already sized up when chrome was removed; the
26px-vs-30px mix is a minor optical inconsistency (plus glyph renders larger
than mic/send). Row inset `px-2 py-2 gap-1.5` (`:4233`).
Pulse matrix (verified):
| Surface | While mic hot | Ref |
|---|---|---|
| Grabber bar | ✅ `animate-pulse bg-accent` | `:509-513`, glow at `:3703` |
| Collapsed pill | ✅ same pulse (`glow={listening \|\| responding}`) | `:582-586`, `:4445` |
| **Overlay mic button** | ❌ static `text-accent` only | `:349-355`, `:4400-4427` |
| ChatSurface mic (kiosk path) | ✅ `active && "animate-pulse"` | glass-composer.tsx:108 |
| HomePill (foundation path) | ✅ `animate-pulse bg-warn/70` on listening | HomePill.tsx:66-68 |
So req 5's pill-pulses-when-collapsed is **done**; the mic button itself not
pulsing on the primary surface is the gap, and it is inconsistent with the
sibling path.
### Jank sources + perf gating
- The height animation is `flexBasis` — layout runs every frame by design (the
pointer-driven MotionValue avoids re-renders but not reflow; `:3985-3995`).
Rows are `React.memo`'d with a comparator (`chat-message.tsx:379,409`);
token streaming is context-isolated (`useShellController.ts:317-325`). No
`content-visibility`/containment anywhere (grep: zero hits) — every open
sheet re-lays-out all ≤80 rows per animation frame.
- Hard perf gates exist and run in CI: `run-chat-perf-gate.mjs` (dropped-frame
ratio, p95 frame time, CLS ≤ 0.1 on the REAL overlay) in
`chat-shell-gestures.yml:160`; frame-glitch outlier detection
(`run-chat-sheet-frame-glitch-e2e.mjs`) at `:169-175`.
- **Known red:** `run-perf-gate-e2e.mjs` has a pre-existing CLS failure — the
fixture layout-shifts **0.80** during scroll+swipe, reproducing on develop
([chat-shell-gestures.yml:18-22](../../../../.github/workflows/chat-shell-gestures.yml))
— yet `test.yml:333` still runs it as a hard-fail leg. Untriaged.
### Test infrastructure (req 6) — strong core, stale edges
Strong: 11 real-browser gesture runners (`packages/ui/src/components/shell/__e2e__/`),
a CHAT_GESTURE_COVERAGE gate that fails CI when a gesture-handler site lacks a
matrix row ([packages/app/test/chat-gesture-coverage.test.ts](../../../app/test/chat-gesture-coverage.test.ts),
docs in `packages/app/docs/CHAT_GESTURE_COVERAGE.md`), WebKit engine coverage
for the scroll chain, and a canary mode proving the glitch detectors fire.
Stale (post-#13531): `chat-clear-swipe.spec.ts:386,424,497` and
`chat-send-voice-newchat-fuzz.spec.ts:325,382` still drive the removed
`chat-full-clear` button; `walkthrough/journey.ts:1108-1121` still expects
`chat-full-maximize`; `chat-clear-swipe.spec.ts` is still in a CI lane
(`scenario-pr.yml:341`). The unit suite already pins those testids as ABSENT
(`ContinuousChatOverlay.test.tsx:2308-2309`) — the repo is testing both sides
of the same contradiction. Minor slop: literal `" "` filler class strings in
`cn()` calls (`:497`, `:570`, HomePill.tsx:54).
## Design considerations
- **Chat is load-bearing for LifeOps.** A parent checking a child's reminder
history, or an ADHD user scrolling back to "what did I agree to yesterday",
hits the pagination wall immediately. History reachability (scroll-up +
search) is MVP-critical, not polish.
- **Never fight the scroll.** The one thing that most degrades trust in a chat
surface is an axis surprise. The fix must be at the scroller (one line +
child audit), not per-widget whack-a-mole.
- **The gesture system is done — protect it, don't touch it.** The detent
physics match the brief (lerp on drag, springs on release, half detent,
pull-to-top maximize). New work must ride the existing perf gates so nothing
slows the drag path.
- **Pagination must not regress the drag path.** Prepending hundreds of rows
into a scroller whose height animates per-frame is the main risk; render
windowing and scroll-anchor math must be part of the same change.
- **Doctrine:** no side panels, no suggestion chips, uniform top bar. Search
therefore lives *inside the sheet* (header affordance at half+), not as a
sidebar. Orange accent only; pulse animations use the existing
`animate-pulse` + `motion-reduce:animate-none` pattern.
## Open questions → answers
**Q1. Pagination: cursor design and interaction with the 80-row render cap?**
A: Add `?before=<messageId>` to GET messages (pivot's `createdAt` pushed into
the store as `end` + `orderBy desc` + `limit 100` — identical mechanics to the
existing around-window, `conversation-routes.ts:1088-1098`). Client keeps a
`hasOlder` flag (last page full ⇒ true). Overlay: top sentinel
(IntersectionObserver inside `#continuous-thread`) triggers the fetch; prepend
with manual scroll-anchor compensation (record `scrollHeight` before, restore
`scrollTop + delta` after — `overflow-anchor` is not implemented in WebKit).
The render cap becomes a *sliding window* over loaded messages (grow by page,
bounded ~400) rather than a hard `slice(-80)`; verify with the existing
`chat-perf-gate` thresholds. Rationale: reuses every mechanism already in the
file; no virtualizer dependency.
**Q2. Where does search live under the no-side-panels doctrine, and what
scope?** A: A search icon in the sheet header's intentionally-empty left slot
(`:3933-3935`), visible at half+ like the launcher button, opening an in-sheet
search row that reuses `MessageSearchPanel` (it is already
presentation-only/injectable). Scope: keep the endpoint's cross-conversation
reach; a hit in the active thread jumps via the existing around-window +
scroll-into-view; a hit in another conversation switches the active
conversation through the existing `handleSelectConversation` handoff — search
becomes the one sanctioned way to reach an older thread now that the switcher
is gone. Undecidable-without-owner: whether cross-thread jump violates the
one-infinite-thread doctrine. **Default: ship active-thread jump first; gate
cross-thread jump behind the same PR only if the owner confirms.**
**Q3. Should the maximize button come back?** A: No. #13531's pull-to-maximize
is implemented, unit-pinned, and e2e-covered. The remaining work is deleting
the stale specs that still look for the button (Q6/issue 5).
**Q4. Which states pulse the mic button?** A: Pulse exactly when the grabber/
pill glow (`listening || responding` drives those); for the button itself,
pulse on `recording || handsFree || transcriptionMode` — the same predicate
that sets `active` today (`:4420`) — so color and motion never disagree.
Respect `motion-reduce:animate-none` (pattern at `:512`).
**Q5. Hide or design horizontal scrolling for wide content?** A: Both, split
by ownership: the transcript gets `overflow-x-hidden` (parity with
`chat-thread-layout.tsx:106`); wide content scrolls *inside its own row*
(`overflow-x-auto overscroll-x-contain`, the `MessageContent.tsx:572` pattern)
— per repo doctrine "wide content must scroll inside its own container".
`MessageAttachments.tsx:831` additionally needs `overscroll-x-contain`.
**Q6. Bring ChatSurface/kiosk and detached-window paths to parity?** A: Not
for MVP. They are desktop/kiosk-only surfaces; the MVP surfaces are web +
mobile where `ContinuousChatOverlay` is the only path. Keep the render-parity
contract green; take no feature work there.
**Q7. Virtualize long threads?** A: No virtualizer for MVP. Measured-first:
add per-row `content-visibility: auto` + `contain-intrinsic-size` only if the
`chat-perf-gate` numbers regress after pagination lands (issue 6 pairs the
CLS triage with this). A virtualizer would fight the `mt-auto` bottom-anchored
layout and the detent height animation for little MVP gain.
## Recommendation (minimal-scope MVP plan, ordered)
1. **P0 — Lock the transcript to vertical scrolling** (1-line scroller fix +
child audit + wheel/deltaX regression e2e). Smallest change, biggest feel
improvement.
2. **P0 — Infinite scroll up with `before`-cursor pagination** (server cursor,
client prepend + anchor, sliding render window, perf-gate verification).
3. **P1 — Wire message search into the overlay** (header affordance reusing
`MessageSearchPanel` + around-window jump; active-thread scope first).
4. **P1 — Mic pulse + composer icon optics** (pulse on the primary surface,
normalize 26/30px optical sizing, keep 44×44 targets; pill pulse already
done — add the regression test).
5. **P1 — Reconcile stale gesture specs with #13531** (delete/rewrite
`chat-full-clear`/`chat-full-maximize` consumers; delete the dead
`verticalScrollPriority` option and filler class strings).
6. **P2 — Triage the perf-gate CLS 0.80 failure** and, only if pagination
regresses frame budgets, add row containment.
Everything else in the brief (detents, springs, pull-to-maximize, pill morph,
haptics, pill pulse, gesture CI) is already implemented and gated — the job is
to not break it, which the existing `chat-shell-gestures.yml` lane enforces.
## Out of scope (explicit non-goals for MVP)
- Feature work on `ChatSurface`/kiosk/detached-window paths (parity contract
only). No consolidation of the three render paths.
- Thread virtualization; framer-motion replacement; transform-based sheet
animation rework.
- Cross-conversation UI beyond search-jump (no switcher/swipe resurrection).
- Semantic/vector message search (keyword ILIKE + BM25 is enough).
- New gesture surfaces (e.g. pull-from-transcript detent control — the grabber
stays the single drag authority; note: any future scroller-attached pull
gesture must use touch events + non-passive `preventDefault`, since
`touch-action: pan-y` compositor scrolling `pointercancel`s pointer-based
gestures).
- Suggestion strips (`SHOW_PROMPT_SUGGESTIONS` stays off, `:223`).
## Proposed issues
1. `[chat-ux]` Lock the chat transcript to vertical-only scrolling (P0)
2. `[chat-ux]` Infinite scroll up: async history pagination in the continuous chat overlay (P0)
3. `[chat-ux]` Wire message search into the primary chat surface (P1)
4. `[chat-ux]` Mic button pulses while recording; normalize composer icon optics (P1)
5. `[chat-ux]` Remove stale gesture specs and dead gesture options left behind by the one-infinite-thread change (P1)
6. `[chat-ux]` Triage the perf-gate CLS failure and guard long-thread scroll performance (P2)