Adds a cross-platform oc-dev menu for web, mobile, Electron, VS Code, and release workflows
Supports user-level config for remote deploys, iOS device preferences, and maintainer-only release tools
Ports local deploy flows from Bash snippets to Node-native operations
* feat(mobile): add Capacitor native shell
* docs: add serve-sim workflow guidance
* docs(mobile): add implementation handoff
* chore(mobile): clean up generated defaults
* feat(mobile): add connection onboarding
* feat(mobile): manage saved instances
* feat(mobile): refine connection management UI
* chore(mobile): upgrade Capacitor 8
* fix(mobile): reliable saved-instance auth with secure token storage
- store client tokens in the OS secure store (iOS Keychain / Android Keystore)
per instance URL via direct native plugin calls; keep only token-less metadata
in localStorage. Bound every secure call so a stalled bridge can't hang unlock.
- bypass the secure-storage JS wrapper's lazy platform load (which stalled in the
webview) by calling internalSetItem/internalGetItem/internalRemoveItem directly.
- harden the shared connect/unlock controller (health + session + progressive
password) and drop the heavy pre-connect hydration that stalled no-token hosts.
- await token persistence before switching runtime endpoints (no fire-and-forget).
- sync native iOS/Android projects + Keyboard/StatusBar config for Capacitor 8.
* fix(mobile): keep UI stable across connection churn (no transport hardcoding)
The "reload every ~10s" was a UX bug, not a transport one:
- MobileSurfaceShell received a fresh inline onClose each parent render, so any
re-render (e.g. an SSE/WS event) re-ran the focus effect and refocused the first
element — stealing focus from the active input and collapsing the keyboard
mid-edit. onClose now lives in a ref so the focus/keydown effect depends only on
`open`. Fixes all sheets (Instances/Files/Changes/Settings).
- Gate the mobile shell on connectionPhase, not the live isConnected flag, so a
transient reconnect keeps MobileShell mounted instead of flashing the loader.
- Instances form: populate fields imperatively on edit/cancel/save instead of via
an effect keyed on the derived connection, so list churn can't wipe input.
Transport stays on `auto` (WS-first with SSE fallback) — no hardcoded override, so
WS-only Quick Tunnels and SSE-capable proxies both keep working.
* feat(mobile): add native QR pairing-code scanner
Wire the connection onboarding + Instances scan buttons to a real native
scanner via @capacitor-mlkit/barcode-scanning, which registers as the
BarcodeScanner plugin the existing mobileQrScan helper already resolves at
runtime. Add NSCameraUsageDescription and bump the iOS deployment target to
15.5 (GoogleMLKit 8 requirement).
* fix(cli): repair connect-url host resolution
Define the missing isWildcardBindHost helper that connect-url called but was
never declared, which crashed any link generation that reached host
resolution. Also treat a full http(s) --host value as a public server URL so
'--host https://example.com' produces a correct link instead of
'http://https://example.com:port'.
* fix(mobile): make input follow the keyboard across all surfaces
Switch the native Capacitor Keyboard plugin to resize: 'none' and drive the
layout from an --oc-keyboard-inset CSS variable set on keyboardWillShow, which
fires at the start of the iOS keyboard animation. A transition tuned to the
native keyboard curve/duration (0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) makes
the layout rise together with the keyboard instead of snapping into place after
the built-in 'native' resize finished (~1.5s lag).
The inset is consumed by every surface that can hold a focused input:
- chat shell shrinks its height;
- portal sheets/overlays raise their bottom edge;
- the full-screen connect/login view caps its height so it actually scrolls
(and is now generally scrollable for long saved-connection lists).
* feat(mobile): rounder chat composer + native bottom safe area
Round the mobile chat composer corners a touch more (1rem), and reserve a small
app-level bottom safe area for the native shell via the --oc-app-bottom-safe
token so controls clear the phone's rounded hardware corners. The reservation
folds into the keyboard inset (no gap above the keyboard), and the composer's
own bottom padding tightens while the keyboard is open.
* fix(mobile): remove iOS 26 dark status-bar band; polish composer
The dark band behind the status bar in system Dark Mode was iOS 26's automatic
scroll edge effect (Liquid Glass) dimming the WebView's top edge beneath the
status bar — appearance-coloured, so it tracked the system theme regardless of
the in-app theme. Hide it via UIScrollView.topEdgeEffect/bottomEdgeEffect on the
WebView's scroll view (iOS 26+), and make the WebView non-opaque so the themed
web background shows under the overlaid status bar.
Also: re-assert the status-bar overlay on resume, paint the document canvas with
the theme background in the native shell, round the composer corners to 1.5rem,
and enlarge the app-level bottom safe area so controls clear the rounded corners.
* feat(mobile): logo splash until first paint is final (no FOUT / layout shift)
Cold start flashed the fallback font and then reflowed once the real font and
persisted appearance prefs landed, and text jumped a frame after mount because the
mobile typography classes were applied from a hook effect. Fix it on three fronts:
- apply device classes (device-mobile / mobile-pointer) synchronously in
renderMobileApp before the first React paint, so mobile --text-* sizes are in
effect from the start;
- hold a logo splash (useFontsReady) until the UI web font has loaded;
- gate that splash on appBootReady too, resolved once async appearance/typography
preferences are applied, plus a double rAF so styles commit before reveal.
All under a 2.5s safety timeout so a slow/offline CDN can't block startup.
* feat(mobile): native local notifications; APNs implemented but frozen
The native app now delivers agent ready/error/question/permission events as iOS
(and Android) Local Notifications: a native notifications API backed by
@capacitor/local-notifications replaces the Web Notifications API (which doesn't
display in a WKWebView), driven by the notification SSE stream now subscribed in
the mobile app. Tapping a notification opens its session. Also fix the settings
toggle, which treated the Capacitor app as a browser and gated 'Enable
Notifications' on the absent Web Notification permission, leaving it un-toggleable.
Remote APNs push is implemented end-to-end (dependency-free HTTP/2 + ES256 JWT
server runtime, token routes, client registration, iOS native config) but kept
dormant: config-gated so it never fires, client registration not wired, and the
aps-environment entitlement / background mode removed so the app builds with no
Apple push setup. It will be reused once OpenChamber ships its own encrypted
relay so users don't each configure APNs. See notifications/APNS.md.
WKWebView can't use web push (unlike an installed PWA), so true
background-when-suspended delivery on native requires APNs via that relay.
* feat(mobile): APNs relay-mode background push
Deliver native iOS background push through the central relay: the server posts
device tokens + generic, model-based text to api.openchamber.dev/v1/push/send
(default), which holds the single APNs key and signs+sends; dead tokens (410)
are dropped from the per-session store. Direct APNs (HTTP/2 + ES256 JWT) stays
as a fallback when OPENCHAMBER_PUSH_RELAY_DISABLED=true. The mobile push payload
is generic only (model + scenario) so no session content crosses the relay.
Re-enable the client token registration (useNativePushRegistration) and the
aps-environment entitlement (alert pushes need no background mode). Wired into
the same fanout as web push; focus-suppressed and only when tokens exist.
* fix(mobile): APNs-only native notifications, generic templates, no foreground
Make APNs the single notification channel for the native app and fix delivery:
- Remove local notifications entirely (the @capacitor/local-notifications plugin
and the SSE-driven path). A WKWebView can't tell foreground from background
(document.hasFocus() is unreliable), so local notifications leaked while the app
was open; the in-app dispatch is no-op'd on native.
- Stop gating APNs on UI visibility — a backgrounded WebView can't report 'hidden'
before iOS suspends it, which dropped background push. Instead always send and let
iOS suppress the foreground banner (PushNotifications presentationOptions: []).
- Fix a ReferenceError (out-of-scope 'variables') that crashed maybeSendPushForTrigger
before any push was sent.
- Mobile push text is generic: a scenario title ('Agent response is ready' / 'needs
your input' / 'needs permission' / 'hit an error') + the session name, no model or
message content.
- Hide the focus toggle, templates, and test button in mobile notification settings.
* feat(push): sign relay requests + bind tokens per server
Each OpenChamber server now auto-generates an ECDSA P-256 keypair (persisted in settings,
like the VAPID keys) and uses it to:
- bind every newly-seen device token to the server on the relay
(POST /v1/push/register-token, signed), and
- sign every push send (publicKeyJwk + ts + signature over ts.sortedTokens.title).
The relay derives serverId = SHA-256(publicKey), verifies the signature + timestamp, and
only delivers to tokens bound to that server. Result: a leaked device token alone can no
longer be used to push to a device — the sender also needs the server's private key. Stays
zero-config (the keypair generates on first use). Drops the soft PUSH_RELAY_TOKEN bearer.
* docs(push): describe relay data-confidentiality model
Document that the push payload is not application-encrypted (TLS-in-transit only), what the
relay and Apple can see (generic scenario title + session name, plus token/sessionId), that
the signature is authentication rather than encryption, and what an end-to-end encrypted
payload would require.
* fix: invalid skill description
* feat(push): app-icon badge for native notifications
Send an absolute aps.badge with each native push = the count of distinct
collapse-ids (tag) pushed since the app was last foregrounded, mirroring the
lock-screen banner stack. Cleared server-side on user engagement (session view,
message-sent, visibility beacon) and on-device via sceneDidBecomeActive.
* feat(mobile): auto-connect last instance on launch + notification deep-links
Cold launch silently reconnects to the most-recent saved instance (when reachable
and a token is saved), holding the splash instead of flashing the connect screen;
falls back to the connect screen when there's no saved instance, it's unreachable,
or it needs a re-login. Notification-tap deep-links are now captured unconditionally
(even before connect / on cold launch) and applied once the app is ready, so a tap
opens the target session instead of being lost on the login screen.
* fix(mobile): resolve theme background before first paint on cold launch
The mobile shell entry (mobile.html) had no pre-paint theme step, so a cold
launch flashed the WebView's default light canvas, then the baked
design-system default (.dark { --background: #151313 }) via body.bg-background,
before React's theme system injected the real theme vars. Add a blocking script
that resolves dark/light from the persisted theme + system preference and sets
--background (plus color-scheme and the element background) inline on the root,
so the very first paint matches the resolved theme. Falls back to the default
flexoki backgrounds when no theme has been persisted yet.
* feat(mobile): openchamber:// deep-link foundation + arm64 simulator build
Add a typed deep-link vocabulary (deepLinks.ts: parse/build + DeepLinkIntent)
and a single native navigation layer (deepLinkNavigation.ts) that handles both
the openchamber:// URL scheme (App.appUrlOpen — widgets, Live Activities,
external links) and notification taps, normalising each into an intent. Session
and new-session resolve against the store; shell surfaces (sessions/settings/
views/changes) register handlers. Cold-launch intents stash until the app is
ready. Replaces the push-only useNativePushDeepLink and keeps backwards
compatibility with bare sessionId payloads.
Register the openchamber:// scheme in Info.plist.
Dev tooling: with-mobile-env now honours xcode-select (-p) instead of hardcoding
Xcode.app, so an Xcode beta is used. build:ios:simulator runs a new
ios-sim-build script that temporarily drops the MLKit barcode-scanning pod
(no arm64-simulator slice) so the app builds an arm64 binary installable on
Apple Silicon simulators, then restores the Podfile + Pods for device builds.
QR scanning already degrades cleanly when the native plugin is absent.
* feat(mobile): iOS home/lock/Control Center widgets + push-driven refresh
Add a Widget Extension (OpenChamberWidget) and a Notification Service Extension
(OpenChamberNotificationService), wired into the Xcode project, sharing an App
Group with the app.
Widgets:
- Overview (medium): recent sessions with read/unread dots + four quick actions
(new, status, instances, settings).
- Sessions (large): session list with per-session project label, attention count
and a new-session button in the header.
- Quick Actions (small): New chat pill + status/instances.
- Lock Screen (accessoryCircular x2): brand logo to new session, attention counter.
- Control Center control: brand logo (custom SF Symbol) to new session.
Data: the app writes a session-overview snapshot (attention count + recent
sessions with project labels) to the App Group on scene activate/resign; the NSE
refreshes it from each push (aps.badge + sessionId) so widgets update even when
the app is closed (needs aps mutable-content, added to the server + relay).
Deep links: add openchamber://status (session status panel) and reuse
view/instances; all widget taps route through the existing deep-link channel.
* feat(mobile): large Sessions widget lists 6 sessions with project labels
* feat(mobile): edge-swipe to switch sessions with directional slide+fade
* fix(mobile): keep widgets in sync via reload-on-change + periodic refresh
Widgets sharing the app's WidgetKit reload budget refreshed unevenly, leaving the
large Sessions widget stale (no unread dot / attention count) while medium updated.
Drop the per-call updatedAt from the snapshot, only write + reloadAllTimelines when
the session overview actually changed (so we don't burn the budget on every scene
activate/resign), and give each widget a periodic timeline refresh so a missed
reload self-corrects.
* feat(mobile): Android support — chrome fixes, SSE lock, icon, QR scan
Cosmetics:
- Status bar: on Android inset the WebView below the bar (overlay:false) and
paint it with the resolved theme background + correct content Style, since
Android doesn't feed env(safe-area-inset-top) to CSS.
- Keyboard: skip the manual --oc-keyboard-inset on Android (the window resizes
natively, so applying it double-counted and floated the composer); declare
windowSoftInputMode=adjustResize and disable the shell height transition on
Android so the header no longer bounces on keyboard open.
Transport: lock Capacitor apps to SSE — native WebSocket streaming is unreliable
on Android (events only arrive once a run finishes). Forced in sync-context and
the other options are disabled in the Chat settings UI.
Push: gate APNs registration to iOS only; on Android @capacitor/push-notifications
register() needs Firebase/FCM (not configured) and crashes at launch.
QR pairing: declare CAMERA permission + the ML Kit barcode_ui dependency, and
install/await the Google barcode scanner module (with a post-install retry) before
scanning so the first scan works without a manual retry.
Icon: Android adaptive launcher icon generated from the cube logo (full-bleed
white background, no edge artifact on One UI). Source assets under mobile/assets.
Tooling: adb-based android-device.mjs + android:* scripts for device deploy.
* feat(notifications): presence-aware push routing (don't spam the phone)
Only push to a device when the notification would otherwise be missed there. A
notification is suppressed on devices where the user is already present.
- Tag every client's visibility beacon and web-push subscription with a platform
('ios' | 'android' | 'vscode' | 'desktop' | 'web') via getClientPlatform().
- Server tracks visibility per client (keyed by oc_ui_session) with the platform,
and exposes isAnyInteractiveClientVisible() = any visible non-mobile client.
- Native push (APNs) and mobile PWA web-push are now suppressed when an
interactive (desktop/web/vscode) client is visible — it already shows the
in-app notification. Gated on the desktop's visibility (reliable), never the
phone's own (a backgrounded WKWebView can't report "hidden").
- Desktop/web web-push keeps the any-visible gate (a visible client absorbs it).
- Skipping APNs also skips the badge increment so it doesn't drift.
Fixes the case where every session on a shared instance pushed to the phone even
while the user was actively working on desktop.
* feat(mobile): Android FCM push notifications
Enable native background push on Android via Firebase Cloud Messaging, in parallel
with the existing iOS APNs path.
- Add google-services.json + declare POST_NOTIFICATIONS (Android 13+). The Google
Services Gradle plugin is applied when the file is present, so register() returns
an FCM token instead of crashing.
- Un-gate native push registration to iOS OR Android, and tag the registered token
with its platform ('ios' | 'android') so the relay routes it to APNs vs FCM.
- Server stores the platform per device token and binds it to the relay (platform
included in the signed register message).
- Notification small icon: monochrome cube silhouette with a mark on the top face,
set as the FCM default_notification_icon so the status-bar icon reads as the logo.
Relay-side FCM sending ships in openchamber-website.
* docs(mobile): refresh HANDOFF with current state, dev/deploy process, and CI gap
* chore(mobile): iOS store-review prerequisites (privacy manifest, encryption flag)
- Add the app's PrivacyInfo.xcprivacy (no tracking; required-reason UserDefaults for the App
Group snapshot shared with the widget + notification service extension) and wire it into the
App target's resources — Apple requires an app-level privacy manifest.
- Set ITSAppUsesNonExemptEncryption=false to skip the per-build export-compliance prompt.
- HANDOFF: add a store-review-readiness checklist (in-repo vs release-time console/infra items).
Verified: plist lint, xcodebuild parse, and an iOS simulator build with PrivacyInfo.xcprivacy
bundled into App.app.
* refactor(mobile): dedupe capacitor detection + make beacon guard explicit
Addresses non-blocking PR review notes:
- Consolidate the repeated Capacitor-native check (mobileConnections, deepLinkNavigation,
usePushVisibilityBeacon each redefined it) onto the single isCapacitorApp() in lib/platform.
- usePushVisibilityBeacon now guards on isWebRuntime() OR isCapacitorApp() instead of relying on
isWebRuntime() being true for Capacitor, so the beacon can't silently stop if that changes.
Render a streaming Thinking block inline instead of inside a capped,
independently-scrollable max-height box (the cap now applies only to finished
thinking, for compact review). The nested scroll box was capturing the wheel and
auto-pinning to its own bottom, so the chat could not be scrolled while thinking
streamed. With it gone the chat's own auto-follow owns the scroll.
Two auto-follow refinements make that solid:
- Direction-aware bottom-zone re-engage: scrolling UP into the bottom spacer zone
no longer re-arms follow (which the next growth would yank back). Follow resumes
only when the user arrives at the bottom by scrolling down, is already
following, or is at the true bottom. Kills the dead-zone fight near the bottom.
- Animation guard: while a Thinking block COLLAPSE animation runs, transient
geometry / trailing async scroll events are treated as our own and never
trigger a false release. Genuine user gestures still release instantly.
The file-reference annotation pass issues filesystem stat probes
(fileReferenceExists -> /api/fs/stat) to decide which inline-code/link tokens
become openable file links. On mobile surfaces this feature is disabled
entirely: gate the annotation effect on !isMobileSurfaceRuntime() so the pass
short-circuits before scheduling, guaranteeing no probe requests are ever sent
from a mobile runtime.
On the first open of a session, late async data (most visibly a task/subagent
tool whose nested rows are fetched from the child session after entry) grew the
timeline a beat after the one-shot entry pin, stranding the viewport mid-history.
The steady-state idle gate intentionally ignores that growth, so re-pinning could
not recover it. Add a short, gesture-cancellable entry-stick window that forces
the bottom on every growth until content quiesces (or the user scrolls), covering
both the ResizeObserver and the structural notifyContentChange path.
A deleted worktree often still has a session in the sidebar, which keeps
polling its PR status — spending a git status call (the source of the noisy
'directory does not exist' errors) plus remote/repo resolution on a gone path.
Bail out early when the directory is missing; the route already returns a
benign no-repo result, which caches so it stops re-polling.
resolveGitHubPrStatus walked remotes and candidate repos one network call at a
time. Resolve all ranked remotes and fetch all candidate repo metadata with
Promise.all instead, preserving rank/priority order and dedup. Cuts wall-clock
on multi-remote/fork setups so a resolution is far less likely to hit the
overall timeout. The PR-search loop keeps its early-return (parallelizing it
would issue more calls, not fewer).
Octokit has no throttling plugin, so under a flood of PR-status calls a
primary/secondary rate limit just surfaced as repeated 403s that the cache
masked. Add a shared rate-limit gate: PR-status sub-calls note 403/429
responses, and the route short-circuits to cached/stale data during the
cooldown instead of issuing more doomed requests. Transient failures
(rate limit or the overall timeout) no longer log as hard errors.
Octokit v22 uses native fetch, which has no built-in timeout, so a stuck
GitHub request hung until the PR-status route's 12s overall budget fired —
and one slow request could consume the entire budget. Wrap fetch with an 8s
AbortSignal.timeout via a shared createOctokit() factory, and route the inline
Octokit instantiations through it too.
getStatus() screamed 'Failed to get Git status' and rethrew for a directory
that no longer exists — a benign case hit when PR-status resolution touches a
worktree that was deleted while still being watched. Treat a missing directory
like a non-repo: skip the error log (callers already handle/swallow it).
The parenthetical explanations wrapped to two lines and added little — the
section header already gives context and the two terms are self-explanatory.
It was rendered in the middle of the checkbox list, where a labeled radio
group reads as out of place. Move it into the radio/choice cluster, right
after Diff Layout, and drop it from the checkbox section's visibility gate.
The Electron-side OpenCode killer kills by port (lsof + kill -KILL).
getOpenCodeProcessInfo returned openCodePort unconditionally, so for an
external/attached OpenCode (e.g. a user's own server on 4096) the only thing
stopping the killer from taking it down was the separate `managed` flag — a
single weak signal guarding a destructive action.
Withhold pid/port unless we actually manage the process, so the killer has no
target even if `managed` is ever miscomputed. Managed flow is unchanged.
A blind probe of the default port 4096 made the desktop hijack a user's
separately-running OpenCode (e.g. the OpenCode desktop app): it attached as
an external server instead of starting its own. That coupled OpenChamber's
lifecycle to the foreign instance and broke initialization against an
unexpected server version/config.
Attaching to an external OpenCode now requires explicit opt-in via env
(OPENCODE_HOST / OPENCODE_PORT / OPENCODE_SKIP_START). Without that, we always
start our own managed instance on a freshly-allocated port.
Watching N worktrees fired N PR-status requests at once (startWatching
called refresh() directly, bypassing the batch limiter). Each request can
take 20s+ under GitHub secondary-rate-limiting, and N of them saturate the
browser's ~6 HTTP/1.1 connections per origin, starving the critical path
(bootstrap session.status, diffs, sending messages) until they finish — the
UI appeared frozen for ~20s on startup.
- Gate all PR-status network calls through a global concurrency semaphore
(max 2), so free sockets always remain for critical traffic.
- Bound resolveGitHubPrStatus with a 12s timeout so a slow request fails
fast instead of holding a socket; the client keeps its last-known status.
- Reuse already-fetched repo metadata for the default branch instead of a
redundant repos.get, reducing serial GitHub calls (less rate-limiting).
Redesign the mobile composer model and agent buttons as borderless, full-bleed
labels that hug their content, truncate with an ellipsis when space is tight,
and show the provider logo inline before the model name. Tighten the footer
action buttons (sessions / attach / auto-accept) so they sit close together,
with a small left inset on the group. In the mobile model selection overlay,
make the thinking-variant control text-only with a chevron, vertically center
the variant and favorite controls in each row, and place the provider logo
inline with the model name.
Give touch surfaces a larger, viewport-relative head start for loading older
history so an in-flight fetch completes before the finger reaches the top.
Raise the mobile virtualizer overscan so fast flings stay populated instead of
leaving blank gaps, and drop the fixed itemSize hint so virtua auto-estimates
row heights from measured sizes instead of a flat constant.
Gate passive auto-follow on active (working/settling) state so idle layout
churn from virtualizer re-measurement no longer re-pins the viewport to the
bottom. Render default-open tool bodies synchronously on mount so the
virtualizer measures the real row height up front instead of growing a frame
later and lurching scroll past several messages.
Marks active embedded chat sessions as seen only while focused
Shows real session titles for context panel chat tabs
Names review sessions after the implementation session
Show a count of chats (root sessions) with unseen activity on the macOS
dock icon. The count is computed in the existing tray snapshot (full
cross-project list, not the capped tray view; a subtask's unseen rolls up
to its root only when subtask notifications are enabled) and pushed to the
main process over the existing desktop_tray_update IPC, which calls
app.setBadgeCount (0 clears it). The badge clears as sessions are marked
seen on window focus.
Add a Dock badge toggle in Appearance settings (default on, persisted,
darwin desktop only), localized across all dictionaries, with a matching
settings-search entry whose availability mirrors the render guard exactly.
Replace the RAF easing follow loop + settle burst with an always-on
instant-follow model: while pinned, the content ResizeObserver re-pins to
the bottom synchronously (scrollTop = scrollHeight, before paint) and is
the only writer of scrollTop. A position+TTL auto marker distinguishes our
own programmatic writes from genuine user scrolling, so a scroll event that
lands at the just-written bottom never trips a false release.
This removes the feedback loop where the easing animation, growing content,
and the user's own scroll all fought for scrollTop in the same frame -- the
infinite twitch when scrolling down during streaming, and the jiggle on
send / from the queue. The public hook interface is unchanged; all
consumers keep working untouched.
Entering a historical session sometimes showed a smooth scroll from a mid
position instead of landing instantly at the bottom. The single-writer change
had startFollowLoop stop the settle burst, so a content-measurement
ResizeObserver tick during restore would downgrade the authoritative instant
pin into an easing follow loop that scrolled in from the partially-measured
position.
- startFollowLoop now yields to an active settle burst instead of stopping it.
The asymmetry is intentional: the settle burst is the authoritative instant
pin (session restore / goToBottom 'instant') and must not be preempted by the
easing loop. startSettleBurst still stops the follow loop, so the two never
write scrollTop in the same frame.
- tickFollow snaps deltas larger than a full viewport (discrete jumps: late
history measurement, session entry, a big block in one commit) instead of
easing them; only small streaming-sized deltas ease.
- restoreSnapshot mirrors goToBottom('instant') — instant write + settle burst,
no startFollowLoop.
Two scroll owners were writing the chat container's scrollTop concurrently
during pinned content growth and on send, fighting frame-to-frame and
producing the reported flicker/jiggle (after a pause, from the queue, on user
interruptions) plus a visible double scroll on a normal user send.
Enforce a single-writer invariant in useChatAutoFollow:
- The easing follow loop and the instant settle burst now mutually exclude:
starting one stops the other, so they can never write scrollTop in the same
frame. The isFollowingProgrammatically flag (which suppresses the overlay
scrollbar) is owned by whichever loop is active and cleared only when both
are idle, including the settle burst's natural 280ms end.
Stop the redundant re-pin storm in useChatTimelineController:
- While pinned, route goToBottom('instant') only for a prepend (history loaded
above), not on every bottom append / streaming part. Normal growth is owned
by the follow loop (kicked by the content ResizeObserver and chunk handlers).
Remove the double movement on send:
- Add scrollToBottomOnSend: when already following, just (re)kick the follow
loop for a single smooth movement instead of also firing an instant
goToBottom that raced the ResizeObserver-driven loop. When released (scrolled
up), keep the instant jump to the just-sent message.
Removes missing expanded folders from persisted Files state
Prevents repeated 404 noise for stale file tree paths
Avoids startup SDK race when restoring sessions
Invalidates cached group ordering when reorder state changes
Keeps dragged worktree groups in the new position immediately after drop
Validated with UI package type-check
Uses the linked target session's last model choice for follow-up review transfers
Prevents reviewer thinking settings from leaking into implementer follow-ups
When older history is prepended while the viewport is pinned to the bottom, the
timeline controller wrote the re-pin manually (scrollTop += delta). That write
is not flagged as programmatic, so useChatAutoFollow's scroll handler treated it
as movement and issued its own correcting scroll — a redundant up/down move on
every prepend. On most setups it settles after one move, but with different
virtualizer measurement/timing it never converges, producing the reported
infinite up/down scroll glitch.
When pinned, delegate the prepend re-pin to auto-follow's goToBottom('instant'):
a single authoritative write to the bottom that IS marked programmatic, so
auto-follow ignores it instead of fighting it. The released case (user reading
back through history) is unchanged and still preserves the read position.
This also covers the on-open history auto-load (loadEarlierIfPinnedViewport-
Underfilled), which only runs while pinned, so its prepends now go through the
single writer too.
Release auto-follow based on position (the user has left the near-bottom zone)
instead of scroll-delta direction. The old `currentTop < previousTop` check
treated the tiny scrollTop clamp the browser applies when the composer grows —
which keeps you at the bottom — as a user scroll-up and released follow, so
content finishing loading then drifted the view backward.
Also always return to the bottom on session switch, dropping the saved-ratio
restore: it had a low success rate and, by landing 'released' partway up,
produced the same visible backward jump as content finished loading.
overflow-anchor is already disabled on the chat scroll container, so no
delta-threshold workaround is needed; this is a net simplification.
After an ungraceful shutdown removePidFile never runs, so a stale
run/openchamber-<port>.pid outlives the process. The kernel can recycle that
PID to an unrelated process, and a liveness-only `process.kill(pid, 0)` check
then reports OpenChamber as "already running" and aborts startup — an infinite
crashloop under systemd Restart=always while the port is actually free
(issue #1721).
Verify identity, not just liveness, but only where it belongs:
- Add isOpenchamberProcessRunning(pid) = liveness + command-line identity, and
use it ONLY at the two sites that validate a PID read from a pid file (the
"already running" guard and the stale pid-file cleanup sweep). isProcessRunning
stays liveness-only for PIDs we know are ours (a freshly spawned daemon child,
processes we are stopping), so those paths cannot get a false negative.
- Identity works on Linux (/proc/<pid>/cmdline) and macOS (ps -o command=); on
Windows or where the command line can't be read it falls back to liveness, so
behaviour is unchanged there with no false negatives.
- Match the "openchamber" install-path segment (present for both @openchamber/web
and a source checkout, foreground and daemon entrypoints alike) so a recycled
stranger such as npm-cli.js or agentmemory is not mistaken for us.
- Clear the stale pid file once its recorded PID is no longer our process.
Adds unit tests for isOpenchamberCmdline and isOpenchamberProcessRunning,
covering the recycled-PID cases and a live non-OpenChamber process.
OpenChamber spawns the OpenCode server as an external child binary (detached
on Unix), so a hard crash, SIGKILL, or Ctrl+C of the host before graceful
teardown could leave it running. Orphaned servers then accumulate and contend
on the shared SQLite DB, causing severe startup slowdowns.
Add a per-process registry plus a startup reaper, mirroring the pattern
OpenCode's own CLI daemon uses for its detached server:
- One file per spawned process at
~/.config/openchamber/managed-opencode/<pid>.json. Per-process files avoid
the read-modify-write clobber race between concurrent runtimes/windows that a
single shared file would suffer.
- On spawn, record the child (pid, owner pid, port, binary, host runtime).
- On graceful close/restart, delete the record.
- On startup, reap only our own, verified, genuinely-orphaned processes:
recorded by us AND still a live `opencode serve` on the recorded port AND
whose spawner is provably gone (reparented to pid 1, or recorded owner dead).
It never touches a process a live instance is using, the user's standalone
server, the official desktop app, or the TUI.
Wire it into every runtime that spawns the server:
- web/desktop via the OpenCode lifecycle (register on spawn, unregister on
close/restart, reap at startup). The restart-for-config-change flow inherits
this automatically through the same kill/spawn paths.
- VS Code carries a parity implementation (it does not bundle the web package)
that reads/writes the same registry directory and uses the same algorithm.
- Tag the actual host runtime (desktop/web/ssh-remote/vscode) for observability.
Also tighten teardown so the registry stays accurate and orphans die promptly
instead of only on the next start:
- The web server now also handles SIGHUP and SIGUSR2 (terminal close and the
nodemon restart used by dev:server:watch / dev:web:hmr).
- Electron now installs SIGINT/SIGTERM/SIGHUP handlers that run the same
background teardown as a normal quit, covering Ctrl+C on electron:dev.
External OpenCode servers (OPENCODE_SKIP_START) are intentionally excluded: we
never manage or kill processes we did not spawn.
Keeps implicit new sessions tied to the current directory
Prevents unmatched directories from inheriting the active project
Adds regression coverage for draft project selection
Lets ArrowUp recall previous messages when the cursor is at the start
Keeps autocomplete guards for history navigation
Restores prior chat input behavior
Makes Commit & Sync fetch and pull before push when needed
Prevents stale git status from showing already up to date
Adds regression coverage for git status cache invalidation
In the changes/diff view, an unwrapped diff's intrinsic width leaked up the
flex chain: the flex-1 column holding the scroll area lacked min-width:0, so its
min-content (the widest line) stretched it — and every nested w-full element,
including the .pierre-diff-wrapper (overflow-x-auto) and the file header — grew
to the content width. That pushed the header's action controls past the narrow
viewport and left overflow-x-auto with nothing to scroll.
Add min-w-0 to the diff layout's flex items so the chain stays at viewport
width: long lines now scroll horizontally inside the diff, and the file header
controls stay visible.
The oc_url_token has a ~50s effective lifetime and was only fetched once at
preview mount, so HTML/image/PDF previews cycled to 'authentication required'
when it expired and nothing forced a re-render with a fresh token.
Add a consumer-gated proactive refresh in runtime-auth: while at least one
url-token consumer is active, a single scheduler mints a fresh token just
before the skew window and swaps it in atomically (the previous token stays
valid until the new one lands — no empty-token window for other consumers).
acquire/release manage the consumer count; subscribe fires only on a real
token replacement.
FilesView consumes this via a shared useAssetAuthRefresh hook (replacing three
near-duplicate effects) and remounts the iframe/img only when the token
actually changes, not on a blind interval.
Wire the unused --markdown-paragraph-spacing token to .markdown-content p so
adjacent paragraphs no longer collapse into a single visual line (Tailwind
preflight had zeroed the default <p> margins).
The renderer wraps each block in a display:contents [data-md-block] element, so
the message-level last-child margin nullifiers target the wrapper, not the
paragraph. Drop the trailing margin on the last paragraph of the last block
directly so messages don't gain extra bottom space. Keep tool-card and
reasoning markdown compact.
Removes startup blocking on OpenCode config defaults
Preserves manual and directory-specific model selections
Adds regression coverage for config races
Require Node.js 22 to match project runtime requirements
Handle malformed or failing node version output safely
Improve install success guidance and PATH diagnostics
Single-dollar $...$ inline math collided with currency text ($50,
US$ 680, "$50M to $72M"), parsing money as math and corrupting it.
Drop single-dollar inline math; keep $$...$$ display math and add
\(...\) inline and \[...\] display via marked tokenizers (caught at
lex time so they survive backslash escaping and stay code-safe).
Also gate renderMathExpressions on a cheap $-presence check so the
split + regex passes are skipped for the non-math majority of blocks.
Draft starters from commands and skills now resolve on mount without needing to open the add dialog
Uses existing TTL-cached and deduped loaders, so no extra cost if already loaded
Desktop notifications no longer duplicate when native delivery succeeds
Reasoning chain-of-thought is excluded from notification body text
Untyped message parts are ignored in notification text extraction
Replaces static context icons with live circular progress indicators
Applies consistent context progress in desktop, mobile, VS Code, and mini-chat headers
Keeps usage coloring tied to existing status thresholds
Reuse the file-editor Shiki extension for the PlanView and SkillsPage editors,
gated to non-markdown files. Markdown sources keep the lezer highlighter (its
markdown-aware styling is better for editing and there's no Shiki view to match).
- PlanView: code files opened through it get Shiki colors; plan .md stays lezer.
- SkillsPage: code supporting files get Shiki colors; SKILL.md stays lezer.