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.
loadProviders dropped the __add_provider__ sentinel selection during a
background provider refresh because it is not a real provider id, which
navigated users out of the in-progress Add provider form and discarded
their unsaved input. Preserve the sentinel alongside real provider ids.
Fixes#1765
* feat(shortcuts): make 'Open model selector' shortcut customizable
Lets users remap the model selector shortcut (e.g. to Ctrl+M) via
Settings > OpenChamber > Shortcuts, matching OpenCode's quick
model-switch keybinding workflow.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* fix(agents): surface manual-restart needed when on external OpenCode
Agent prompt/permission/settings edits are written to disk, but an
external OpenCode server (skip-start or auto-detected on the default
port) is not owned by OpenChamber and is only health-probed on config
change, so it keeps serving its startup-cached config until restarted.
The API previously claimed a successful reload, so the UI silently
reverted the edit to the stale/default value on refresh.
Now refreshOpenCodeAfterConfigChange reports whether a real reload
happened; agent routes return requiresManualRestart for external mode;
and the agents UI keeps the saved values and warns the user to restart
their OpenCode server instead of showing a false success. Managed mode
behavior is unchanged (process is restarted and reload is live).
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
---------
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* feat(model-picker): drag-to-reorder providers and persist accordion state
Ports two model/provider picker QoL features from otto-ui:
- Persist collapsed state of picker sections (favorites, recent, each
provider) via a new persisted zustand store so collapse survives
remounts and reloads, shared across every picker surface.
- Desktop drag-to-reorder of provider sections (whole header as the
mouse activator, 8px threshold so a plain click still toggles
collapse), with the order persisted in useUIStore.providerOrder and
applied across ModelControls, ModelMultiSelect and ModelSelector.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* feat(sessions): shift-click quick action hard-deletes thread without prompt
Ports the shift-quick-delete feature from otto-ui. The sidebar quick
action (normally archive) becomes a no-prompt hard delete while Shift is
held: the icon switches to a trash bin, the affordance turns destructive,
and the click bypasses the confirmation dialog via a new skipConfirm
source flag. A shared useShiftKeyHeld hook (single window listener set,
useSyncExternalStore) keeps only the small action button re-rendering on
Shift state changes, and resets on window blur so the affordance can't
get stuck after alt-tab.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
---------
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
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
Sending a message while a question prompt was open left the prompt
lingering, blocked the send, or collided with the still-blocked agent
turn. Two root causes:
useSessionActivity treated pending permissions as idle but not pending
questions, so the send button became Stop during a question and Enter
queued/collided instead of sending. handleSubmit also never dismissed
the open question, stranding the session in a half-answered state.
The send path now dismisses open questions for the session subtree
(optimistic local clear so the card vanishes instantly, plus a formal
question.reject) and queues the message. The queued-message auto-send
hook then delivers it as the next turn once the rejected turn winds
down and the session returns to idle. Queueing avoids aborting the
turn, which surfaced an unwanted "running turn was stopped" notice.
Regression tests cover the no-op, subtree dismissal (root + subagent
child), and QuestionNotFoundError paths.
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.
Replace useEffect with useLayoutEffect in the pendingInitialRestoreRef
replay so restoreSnapshot runs synchronously after DOM commit, before
the browser paints. Prevents visible flash of content at the wrong
scroll position when the scroll container mounts after session
hydration.
Adapted from openchamber/openchamber#1553 (Fix 2). The virtualVersion
counter (Fix 1) is not applicable: virtua (post #1651) does not use
useVirtualizer's useState-based instance pattern that motivated it.
Validation:
- bun --cwd packages/ui type-check - no new errors in useChatAutoFollow.ts
- bun --cwd packages/ui lint - passed
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
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.
When clearing temperature or topP on an existing agent, the UI sent
undefined which JSON.stringify drops, so the server never received the
clear command. Now sends null to properly remove the override in
opencode.json.
Changed updateAgent to use 'field' in config pattern for temperature
and top_p, matching the existing prompt handling.
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
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
* feat(agents): expose thinking variant configuration in agent settings
Fix#1425: add variant field to agent config UI so users can configure
thinking/reasoning depth per agent without editing opencode.json.
Changes:
- Added variant to AgentConfig and AgentDraft types in useAgentsStore
- Pass variant in createAgent and updateAgent API calls
- Support null for temperature, top_p, and variant to clear overrides
- Added variant input field in AgentsPage 'Model & Parameters' section
- Added variant to settings search registry
- Added i18n strings for variant field in all 8 non-English locales
The variant field maps to provider-specific parameters (e.g. Anthropic
high/max variant, OpenAI reasoning effort). Users can enter any string
value; the SDK passes it through to the model provider.
Clearing temperature/topP/variant now sends null to the server instead
of omitting the field, which properly removes the override in
opencode.json.
* fix(sync): preserve tool state.time in materialization merge
* chore: trigger re-review
* fix(agents): use thinking variant selector in settings
* fix(agents): preserve thinking variant values
---------
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
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
Fix#1462: handleDisconnectProvider called the SDK auth.remove() which
only clears auth credentials from auth.json. Cloud providers configured
in user/project/custom config files were not removed and reappeared
after reload. Now calls DELETE /api/provider/:id/auth?scope=all which
removes the provider from all config sources.
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
* fix(agents): use isPrimaryMode filter for agent picker
Fix#1527: agent picker filtered by mode !== 'subagent' which missed
agents with unexpected mode values. Now uses isPrimaryMode() which
only includes 'primary', 'all', undefined, and null — the semantically
correct set of agents that should appear in the picker.
* fix(agents): use isPrimaryMode consistently across all agent pickers
Updated AgentSelector.tsx to use isPrimaryMode instead of mode !== 'subagent'.
Removed duplicate isPrimaryMode definition from useConfigStore.ts and
imported the shared helper from mobileControlsUtils.
---------
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
* fix(chat): preserve tool duration across session switches
Fix#1636: ToolPart.tsx reset pinnedTime to empty on unmount/remount,
causing LiveDuration to not render on first paint. Now initializes
pinnedTime from server-provided time?.start/time?.end in the useState
initializer, eliminating the one-frame gap.
* fix(sync): preserve tool state.time in materialization merge
---------
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
* fix(sidebar): increase virtualizer buffer for expanded parents
Fix#1530: archive sub-session layout broken because the virtualizer
used a fixed 28px height estimate per row. Expanded parents with inline
children are much taller. Now dynamically increases bufferSize when
expanded parents are present.
* fix(sidebar): correct expansion-key format for virtualizer buffer
The expansion key was using raw sessionId instead of the scoped format
'project:{archived|active}:{sessionId}'. This made hasExpandedParent
always false, so bufferSize never increased. Also removed dead
hasSessionSearchQuery branch.
---------
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
* fix(sync): reflect share status from global store after cancel
Fix#1551: unshareSession() called updateLiveSession() which silently
fails when the child store doesn't exist. The sidebar rendered from the
child store first, showing stale share data. Now overlays the global
session's share field at merge points.
* fix(sync): extract shared mergeLiveSessionWithGlobalSession helper
Extracted the share-field overlay into a single shared helper in
useGlobalSessionsStore.ts. All 3 merge sites now use the helper
instead of duplicating the overlay logic.
* test(sync): add unit tests for mergeLiveSessionWithGlobalSession helper
---------
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
* fix(session): bind new sessions to selected project
Fix#1521: openNewSessionDraft() always used currentDirectory even when
the user selected a different project. Now prefers the selected project's
path when no explicit directory is provided.
* test(session): add unit test for openNewSessionDraft project binding
---------
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
Added sessions.length === 0 guard to useSidebarPersistence.ts and
sessions.length === 0 && archivedSessions.length === 0 guard to
useSessionFolderCleanup.ts. Prevents data loss when server returns
empty list during transient failures.
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Fix#1685: the Basic auth header for the OpenCode server was hardcoded
to use the username 'opencode', ignoring OPENCODE_SERVER_USERNAME. Users
who set a custom username got 401 errors because the server expected a
different credential.
Both call sites (web server auth-state-runtime.js and VS Code
extension opencode.ts) now read process.env.OPENCODE_SERVER_USERNAME
with a fallback to 'opencode' to preserve prior behavior.
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
* fix(settings): persist per-model visibility and sibling selector state
The server-side settings sanitizer only allowlisted favoriteModels and
recentModels, so hiddenModels, collapsedModelProviders, recentAgents, and
recentEfforts were stripped on every write to settings.json — per-model
visibility and collapsed-provider state silently reset on every container
redeploy or settings reload.
Add the four missing fields to sanitizeSettingsUpdate:
- hiddenModels: sanitizeModelRefs(..., 1024) — same shape as favoriteModels;
1024 covers dense multi-provider setups while bounding persistence/memory.
- collapsedModelProviders: normalizeStringArray with Array.isArray gate
(matches usageDropdownProviders).
- recentAgents: normalizeStringArray (Array<string> per ui-store).
- recentEfforts: new sanitizeRecentEfforts validating Record<string, string[]>
(shape confirmed in ui-store + addRecentEffort action); trims/dedupes keys
and variants, caps at 128 keys x 5 variants/key (5 matches client slice).
No ui-store version bump or migration: zustand's default merge spreads
persisted state over defaults, so missing fields fall back to [] / {} until
the next toggle. favoriteModels and recentModels are untouched.
Tests: 8 new cases in settings-helpers.test.js using the real
sanitizeModelRefs / normalizeStringArray — round-trips, empty-[] parity with
favoriteModels, garbage rejection, and a full-payload regression test.
* fix: sync model selector settings
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
* fix(mobile): use exact directory matching for session grouping
The new mobile sessions sheet used startsWith prefix matching to
assign sessions to projects, which caused child-directory sessions
(e.g. /root/repos/opencode) to be grouped into parent projects
(e.g. /root/repos). Switch to exact directory matching (project root
or registered worktree paths only) to match the desktop sidebar
behavior.
Also exclude sub-agent sessions (those with parentID) from the
totalSessions badge count so the displayed number reflects only
top-level sessions.
* fix: align mobile session project matching
---------
Co-authored-by: lilyzhaun <lilyzhaun@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
* fix: handle non-ISO-8859-1 characters in fetch headers and Content-Disposition
Browser Headers API rejects characters above U+00FF. The x-opencode-directory header carries raw filesystem paths, which breaks when paths contain Chinese/CJK characters. Also fixes Content-Disposition for non-ASCII filenames per RFC 5987.
* refactor: export header sanitization helpers, deduplicate, add tests
Export isLatin1Safe and sanitizeHeadersForBrowser from runtime-fetch.ts so VS Code webview can import them instead of duplicating the logic. Add tests: isLatin1Safe boundary checks, sanitizeHeadersForBrowser encoding/deduplication, runtimeFetch round-trip encode/decode, and Content-Disposition RFC 5987 output for both ASCII and non-ASCII filenames.
* fix: mark encoded directory headers
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>