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

18 KiB
Raw Permalink Blame History

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, packages/ui/src/components/shell/ContinuousChatOverlay.tsx:128-157). 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, App.tsx:539).
  3. ChatView (1,536 lines) — desktop detached chat windows only, via packages/app-core/src/runtime/desktop/AppWindowRenderer.tsx:44 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. 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) 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, 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, :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) and the overlay renders at most the last 80 rows (MAX_RENDERED_SHELL_MESSAGES, packages/ui/src/components/shell/shell-state.ts:60). 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); 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

Mic + '+' buttons (req 5)

SoftButton (ContinuousChatOverlay.tsx:309-370): 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) — 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, 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 pointercancels 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)