Load startup config under the owning project's directory key (resolving from a
worktree directory when needed) so the auto-opened draft, which activates the
project, finds a ready providers/agents snapshot instead of triggering a second
load. Also dedupe app.agents: listAgents now takes the directory directly and
shares an in-flight request, so the config store and agents store no longer
issue duplicate agent fetches at startup.
A new draft session inherited the previous session's model/agent instead of
resetting to defaults, because opening a draft restored the directory snapshot
without re-applying the startup default cascade. When the prior session ran in
a worktree, defaults were resolved against the worktree directory's provider
list, which omits project/global-scoped providers, so the default agent's model
fell back to opencode/big-pickle.
Resolve the default agent/model via a shared cascade (settings default ->
OpenCode default_agent -> build -> first), resolve the model from the agent's
pinned model/variant or OpenCode's config model, and activate the project's
config (not the worktree's) when opening a draft.
The global event-stream WebSocket opened before a valid oc_url_token was
minted, so the upgrade failed auth ("no valid credentials available") in
packaged builds with a UI password. The resulting reconnect storm churned
the sync store and made session status flicker busy<->idle. Await the URL
auth token before connecting (a WS upgrade can't send a bearer header like
SSE does) and drop a rejected token on pre-ready close so the next attempt
re-mints a fresh one.
Also harden /session/status reconciliation: the watchdog poll is now
monotonic (only confirms/raises active status, never blindly lowers a
busy/retry session to idle on a transient or misscoped snapshot). Idle is
applied only by the authoritative reconnect/escalation resync, which trusts
the live server snapshot as the source of truth. Add a Help -> Toggle
Developer Tools menu item so production builds can open the console.
Add per-hunk staging, unstaging, and discarding to the Changes diff
view, so a single change region inside a file can be acted on in
isolation instead of forcing whole-file stage/revert. The change is
wired end-to-end across the web server, the shared UI runtime API
contract, and the VS Code extension, with Electron inheriting the web
path unchanged (it boots the server in-process).
Server
------
- New `applyHunk(directory, filePath, { patch, action })` in
packages/web/server/lib/git/service.js. It resolves the repository
context and validates the file path with the same helpers used by
stageFiles/unstageFiles (resolveGitFileContext +
validateRepositoryFilePaths), then writes the single-hunk patch to a
temporary file in the OS temp dir (never inside the repo, so it
cannot show up as an untracked file) and runs `git apply` with flags
chosen per action:
stage -> git apply --cached (working tree -> index)
unstage -> git apply --cached --reverse (index -> working tree)
discard -> git apply --reverse (revert in working tree)
A `git apply --check` runs first with the same flags, so a stale
hunk that no longer applies fails with a clear "Hunk no longer
applies - refresh and try again" message instead of leaving a
partial mutation. The patch's target path is parsed and must match
the requested file (with /dev/null tolerated for new/deleted files),
preventing a patch from silently targeting a different path. The
whole operation runs inside withGitIndexMutationQueue to avoid
racing with concurrent stage/unstage. The temp file is removed in a
finally block.
- New `POST /api/git/apply-hunk` route in routes.js, registered
alongside stage/unstage. Validates directory, path, non-empty patch,
and action before delegating.
- DOCUMENTATION.md updated with the new service entry.
Patch extraction
----------------
- packages/ui/src/lib/diff/patchFileDiff.ts gains
splitPatchIntoHunks(patch) and extractHunkPatch(patch, hunkIndex).
They keep the original file header (diff --git / index / --- / +++)
and emit exactly one @@ hunk per standalone patch, which is what
`git apply` expects. Each emitted patch is guaranteed to end with a
trailing newline (without it git apply reports "corrupt patch").
Runtime API contract
--------------------
- GitAPI (packages/ui/src/lib/api/types.ts) gains optional
stageGitHunk / unstageGitHunk / revertGitHunk, matching the
stageGitFiles? / unstageGitFiles? precedent so runtimes that do not
support it degrade gracefully.
- gitApi.ts delegates to the registered runtime git API, falling back
to gitApiHttp, exactly like the existing whole-file helpers.
- gitApiHttp.ts posts to /api/git/apply-hunk.
- Web runtime composes the three methods in packages/web/src/api/git.ts.
VS Code parity
--------------
- packages/vscode/src/gitService.ts adds applyGitHunk(), implemented
natively with the existing execGit helper + a temp patch file +
`git apply` (--cached / --cached --reverse / --reverse), mirroring
the server's --check-first safety and temp-file cleanup.
- bridge-git-runtime.ts handles the new api:git/apply-hunk bridge
message; webview/api/git.ts sends it. VS Code users get identical
stage/unstage/discard-hunk behavior.
UI
--
- New DiffHunkActions component renders a compact per-hunk strip
above each expanded file diff in the Changes view. Each hunk chip
shows its +additions / -deletions counts and offers:
working scope -> Stage + Discard
staged scope -> Unstage
Clicking extracts that hunk's standalone patch via
extractHunkPatch(patch, hunkIndex) and calls the runtime git API.
Because the chip index comes directly from fileDiff.hunks[] and the
patch is sliced in the same order, the hunk the user sees is always
the hunk that gets applied. While any action is in flight all buttons
disable to prevent conflicting concurrent mutations; the per-hunk
spinner reflects in-flight state.
- DiffView wires DiffHunkActions into InlineDiffViewer (text diffs
only; binary/image and full-file-content modes are excluded since
they have no patch). MultiFileDiffEntry passes directory/staged
through and handles onHunkApplied by bumping the diff reload nonce
(so the file's diff re-fetches and the affected hunk disappears)
and refreshing git status (so file counts and the staged/changed
scope update). Hunk actions are therefore available wherever the
default patch-context diff is shown.
i18n
----
- 10 new keys (diffView.hunk.*) added to all 9 locales (en, es, fr,
ko, pl, pt-BR, uk, zh-CN, zh-TW), including stage/unstage/discard
labels, tooltips with the hunk index, a stale-hunk error message,
and an unsupported-runtime fallback.
Tests
-----
- packages/ui/src/lib/diff/patchFileDiff.test.ts covers
splitHunks/extractHunkPatch: multi-hunk split, header preservation,
single-hunk and empty patches, out-of-range indices.
- service.test.js adds an applyHunk suite that builds real temp repos
with two separate hunks and verifies: staging one hunk leaves the
other unstaged, discarding reverts only the targeted hunk in the
working tree, unstaging removes only one hunk from the index, and a
retargeted patch (different file path) is rejected. Also covers
invalid-action / missing-hunk-header validation.
- packages/web/src/api/git.test.ts mock completed with the new methods
(and previously-missing exports that prevented the test from
loading) and asserts the three hunk methods are exposed.
- routes.test.js continues to pass under bun.
CHANGELOG updated under [Unreleased].
The worktree dialog state was local React state in SessionSidebar,
which gets destroyed when the component unmounts (mobile drawer close,
VSCode view switch). This caused the modal to briefly appear then
disappear.
Three changes:
- Move newWorktreeDialogOpen from local state to useUIStore so it
survives component unmount
- Guard useProjectSessionSelection layout effect to skip when the
worktree dialog is open (prevents auto-session-selection from
closing the sidebar)
- Remove setSessionSwitcherOpen(false) from worktree button handler
on mobile (prevents drawer close that unmounts the sidebar)
Fixes#1414
* repro: add test reproducing issue #1564 - empty branch list in create worktree dialog
The NewWorktreeDialog does not auto-fetch branches when it opens
if branches haven't been cached yet. Branches only get populated
if the Git tab has been opened or a draft session has been started,
both of which call fetchBranches/fetchAll independently.
The test demonstrates:
1. Branches are null (empty list) for directories not yet fetched
2. Branches are available after fetchBranches is called
3. The ChatInput's draft session branch fetch explains why the
issue says 'list gets populated after starting a draft session'
4. The missing auto-fetch call when dialog opens
* fix: auto-fetch branches when opening create worktree dialog
---------
Co-authored-by: Reproduction Bot <repro-bot@openchamber.dev>
Keeps typed or pasted comment text when the editor remounts
Prevents focus changes from dismissing active inline comments
Allows cancelling by pressing the selected line number again
ArrowUp in chat input now moves caret instead of recalling history when the field has text
Removed manual cache-busting workaround and eslint-disable in favor of idiomatic onChange state sync
Extends existing file-reference detection (currently only inline code and
anchor tags) to also wrap path-like tokens inside <pre><code> blocks,
making shell-output paths like 'src/foo.ts:42' clickable just like
inline ones.
Implementation in MarkdownRendererImpl.tsx:
- New regex BLOCK_PATH_TOKEN_RE matches paths with mandatory extension
and optional :line[:col] suffix.
- New helper wrapBlockCodePathTokens() walks text nodes in each
rendered <pre><code>, wraps matches in
<span data-openchamber-block-path-token="true">, and marks the
block as scanned (data-openchamber-block-paths-scanned) to avoid
re-walking.
- annotateFileLinks() invokes the wrapper and extends its selector
to include the new tokens; the rest of the pipeline (stat check,
click handler) is reused unchanged.
- Code blocks longer than 200KB are skipped to keep large outputs
(git log, build logs) cheap.
The OpenCode server cascade-deletes all child sessions when a parent
is removed. The client was sending individual DELETE requests for each
descendant, which returned 404 after the parent's cascade removed them.
The 404 triggered rollback in deleteSessionAction, restoring already-
deleted sessions back into the global store.
Changes:
- executeDeleteSession: only send the root session delete; the server
cascade handles descendants.
- deleteSession / deleteSessionInDirectory: treat 404 in catch as
success, acting as a safety net for remaining paths (e.g. sidebar
bulk action bar when parent and child are both selected).
When switching to a session with long context (especially in Electron
desktop when changing servers), the chat viewport could render blank
until the user scrolled. Two interacting issues caused this:
1. historyVirtualRows memo never recomputed after the first render
because historyVirtualizer (from useVirtualizer's useState) is a
stable reference. Frozen range meant items rendered at the top
while paddingBottom filled the visible viewport after scrolling.
2. pendingInitialRestoreRef replay ran in useEffect (after paint),
showing a frame at scrollTop:0 with the stale virtualizer range.
Fixed by: adding a virtualVersion counter driven by useVirtualizer's
onChange to bust the memo; switching the replay to useLayoutEffect
so scroll position is set before the browser paints.
Replace useDirectoryStore.currentDirectory with useEffectiveDirectory() in
both TaskToolSummary and ToolPartContent to align the directory key used for
ContextPanel tab storage/lookup. Previously ToolPart used the global project
root while ContextPanel resolved session/worktree-scoped directories via
useEffectiveDirectory(), causing a key mismatch that left the iframe blank
when opening sub-tasks from chat messages.
Skill/snippet/command/file-mention autocomplete branches in ChatInput
called preventDefault()+return but not stopPropagation(). The Tab key
bubbled to the global window keydown listener in useKeyboardShortcuts,
whose cycle_agent shortcut (default 'tab') then ran setAgent().
Add e.stopPropagation() to all four autocomplete branches so the
synthetic event no longer reaches the window-level handler when an
autocomplete consumes the key.
Selection highlight now visually wraps status indicators and chevrons
Active session gets a subtle primary background tint
Multi-select rows use the interactive-selection token
Packaged desktop showed no sessions in 1.12.4. Root cause: the sanitized
session-list proxy path added in #1538 forwarded the renderer's
"authorization" header (the OpenChamber UI client token) to the managed
OpenCode upstream alongside the managed "Authorization" credential.
OpenCode does not recognize UI client tokens, so every session-list
request answered 401 — only in the packaged app, because only its
renderer (openchamber-ui:// origin) attaches a bearer token; dev web and
dev Electron run same-origin without one. The legacy http-proxy path
overwrote the header correctly, which is why everything except session
lists kept working.
Proxy fix:
- proxy-headers: filter the client "authorization" header out of
forwarded request headers; the OpenCode upstream must only ever see
its own managed credentials. Covered by tests.
Desktop cwd:
- electron: launch the managed OpenCode CLI from the user home instead
of app userData, matching upstream desktop behavior. userData-as-cwd
made OpenCode treat the app-data folder as a separate empty workspace.
Home directory poisoning loop:
- directoryPersistence: stop replaying localStorage homeDirectory
through synchronizeHomeDirectory on boot/auth resync. The persisted
value is only a boot-time cache; replaying it re-wrote stale values
(e.g. a project path) into desktop settings on every start, overriding
the authoritative /api/fs/home resolution.
- persistence: never overwrite an injected window.__OPENCHAMBER_HOME__
with a persisted value.
- useDirectoryStore: host switches happen in place (no reload), so
re-resolve home from the new runtime's /api/fs/home on endpoint
change instead of keeping the previous host's value.
- opencode client: only short-circuit to the injected desktop home when
the active runtime is local; remote runtimes ask /api/fs/home.
Settings hygiene:
- persistSettings: log field names only — change payloads can carry
credentials (UI password, client tokens, tunnel tokens) that must not
reach the log file; drop step-by-step log chatter.
- validateProjectEntries: only stat project paths when the incoming
update actually touches the projects list, not on every settings save.
- remove the write-only approvedDirectories setting everywhere and add
a migration that strips the stale key from persisted settings.
Tests:
- usePluginsStore.test: register an own runtime-fetch module mock so the
suite is independent of process-global mock.module leakage from other
files, and restore globalThis.fetch after the suite.
- persistence.test: clean up the window global created for the suite.
* lighten session list
* fetch full session on open
* sanitize session list
* sanitize global sessions
* preserve revert markers in session lists
* fix: preserve session metadata in list sanitizers
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
The newer mobile Changes surface returns early for non-interactive list states (no directory selected, repository status still loading, or current directory is not a Git repository). Those branches rendered only MobileChangesState and skipped the standard header row entirely.
On Android PWA this leaves the sheet with no visible close/back affordance. Because the surface is hosted inside MobileSurfaceShell as a modal sheet, the browser/system back gesture does not reliably dismiss it, so users were forced to tap sparse overlay whitespace to escape.
Fix the regression by wrapping those early-return states in the same top header used by the normal Changes list, including the close button and current path label. This keeps dismissal available even when the new mobile UI is showing an empty/error state. While touching the file, switch the remaining direct @remixicon/react usages in this component to the shared Icon system to match current UI conventions.
Validated with packages/ui type-check and a packages/web build.
Co-authored-by: lilyzhaun <lilyzhaun@users.noreply.github.com>
* feat(ui): add cache hit rate to context sidebar with verified formula
Add a Cache Hit row to the last-assistant-message token breakdown in
the context sidebar. The percentage is computed by the new
computeCacheHitRate utility:
cache.read / (input + cache.read + cache.write) x 100
The formula was verified against the SDK source
(packages/opencode/src/session/session.ts:getUsage), which reports
input as the non-cached portion only
(totalInputTokens - cacheReadInputTokens - cacheWriteInputTokens).
Also export sumTokenBreakdown from tokenUtils for reuse, and fix the
event-reducer test type errors (setDelta/getText helpers, toBeCloseTo
replacement).
Closes: #
* fix: wire i18n key for Cache Hit label, fix formatNumber type, drop unintended event-reducer changes
* chore: remove unused cacheHitRate and cacheHitRateTooltip i18n keys from en.ts
* fix: correct cache hit token display
* fix: add French cache hit label
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>