The previous system layered three hooks (useScrollEngine, useChatScrollManager,
useChatTimelineController) with overlapping responsibilities, four parallel
ResizeObservers/MutationObservers, and six entry points to "scroll to bottom"
(force-flag combinations, persistent follow loops, materialization recovery).
This produced bugs where users could not break free of auto-follow during
streaming: scrollbar drag, keyboard scrolling and find-in-page were not
detected as user intent, and observers kept restarting the follow loop on
every DOM mutation.
The new architecture replaces the two low-level hooks with a single
useChatAutoFollow that owns scroll behaviour end to end:
- One state: 'following' or 'released'. No follow modes, no pin flags,
no marker pixels.
- One scroll writer: a lerp loop that runs only while the session is
streaming and state is 'following'. Idle sessions never write scrollTop
programmatically.
- One user-intent detector: wheel up, touch drag down, keyboard
(PageUp / Home / ArrowUp), pointerdown on the OverlayScrollbar thumb,
and explicit releaseAutoFollow() calls all flip the state to 'released'.
- A 1.2s grace period after explicit release: re-pin will not auto-engage
inside this window, so a small wheel up cannot snap the user back even
while they remain near the bottom spacer.
- Re-pin and the scroll-to-bottom button share the same threshold: the
height of the empty bottom spacer (10vh on desktop, 40px on mobile).
Released users see the button only after they have scrolled past the
spacer that already exists at the end of the chat.
- Save/restore of scroll position uses ratio mapping, debounced at 150ms
on user-driven scroll events; programmatic writes are masked via a short
window so they never persist as user positions.
- Container reattachment is detected via a useLayoutEffect probe over
scrollRef.current. Listeners and observers re-bind when ChatViewport
mounts after hydration or after the first message promotes a draft
session into a real chat.
- A pending-restore queue replays restoreSnapshot once the scroll
container appears, fixing the case where a hydrating session landed at
the top instead of the bottom.
Removed: useScrollEngine.ts, useChatScrollManager.ts, the persistent
follow loop with its own ResizeObserver+MutationObserver pair, the
materialization-recovery .finally resume that yanked idle users to the
bottom on transient sync gaps, and the openchamber:session-reselected
event (re-select still works through the existing onSessionSelected
callback). The openchamber:chat-force-scroll-bottom event remains for
synthetic-message paths like git-message generation.
Net change: ~1300 lines removed, two hooks replaced with one, one
observer pair instead of four.
Run OpenChamber and OpenCode as separate persistent services — useful when you want to access your
dev machine over a VPN (e.g. Tailscale) or LAN without a Cloudflare tunnel.
How it works:
OpenCode runs as its own service, binding only to localhost.
OpenChamber connects to it via OPENCODE_HOST and --host 0.0.0.0 makes it reachable on your VPN IP.
--foreground keeps the CLI process alive so systemd can track and restart it.
Why set PATH and SSH_AUTH_SOCK?
systemd user services start with a minimal environment — no shell profile is sourced.
Without an explicit PATH, OpenCode won't find tools installed via Homebrew, npm, or ~/.local/bin.
Without SSH_AUTH_SOCK, git operations over SSH (push, pull, clone) will fail because the agent socket isn't inherited.
Adjust the PATH to match your own tool installation paths.
%t expands to $XDG_RUNTIME_DIR (e.g. /run/user/1000), where most SSH agents write their socket.
OpenChamber will be reachable at http://<your-vpn-hostname>:3000 from any device on your VPN.
Note:--host 0.0.0.0 is required to listen on all interfaces. The default
bind address is 127.0.0.1 (localhost only). Use --host <ip> or
OPENCHAMBER_HOST=<ip> to bind to a specific interface instead.
Managed-local path note: OPENCHAMBER_TUNNEL_CONFIG must point to a path inside the container user home (/home/openchamber/...). If your Cloudflare config references a credentials JSON file, that file path must also be accessible inside the container (mount with volumes).
Website docs source lives at packages/docs/content/docs/reverse-proxy.mdx.
Tunnel behavior notes
OpenChamber supports one active tunnel per running instance (port).
Starting a tunnel with a different mode/provider on the same instance replaces the current tunnel.
Replacing or stopping a tunnel revokes existing connect links and invalidates remote tunnel sessions for that instance.
Connect links are one-time tokens; generating a new link revokes the previous unused link.
Data Directory Permission Note: The data/ directory is mounted into the container for persistent storage (config, sessions, SSH keys, workspaces). Before running, ensure the directory exists and has proper permissions:
My wife, who - with zero AI background - sat down with the app for the first time and built the firework celebration that plays on every successful push.
Every contributor who shaped this project with their PRs, ideas, and attention to detail.