A single fast probe (2.5s per transport) used to be the only chance a
connection got on cold launch and resume, so a just-woken network, a
WireGuard re-handshake, or a relay cold start (TLS + WS + E2EE) regularly
produced false "unreachable" verdicts that kicked the user to the connect
screen. Now:
- cold launch releases the splash on the fast verdict and retries once in
the background with the full connect budget — a reachable instance
reconnects on its own, and a manual connect started meanwhile wins;
- resume retries on a 4s/10s ladder, the last attempt with the full budget,
before tearing the connection down; needs-login still disconnects
immediately on every path;
- full-budget relay probes are capped at the shared 8s connect budget
instead of inheriting the 15s relay session default, so a genuinely dead
server does not pin the retry for 15 extra seconds.
Probe steps, budgets, and retry decisions all land in the connection log.
Connection lifecycle events (probes, transport failures, resume decisions)
are mirrored into an in-memory trail that resets on every launch. A long
press on the connect-screen logo or the instances list opens a panel that
renders the trail with one-tap copy, so release builds can report the exact
probe sequence without a tethered debugger. Details reuse the already-masked
log payloads — no tokens or secrets are captured.
A request body stream that yields no chunks still declares hasBody in
the request head; send one empty HttpBody frame so the host can tell an
intentionally empty body apart from body frames lost in transit.
Settle the buffered-body wait when the delivery deadline fires so the
stream's buffered chunks are freed immediately, and make the deadline
injectable for tests. Covers the stalled-mid-body path with a test.
Creating a device key while the UI is open through a public https domain
(reverse proxy) dropped that domain from the QR payload whenever the
dialog passed a preferred LAN URL, leaving only the local IP and relay
as transports. Carry the non-loopback request origin as an additional
direct candidate (priority 20, between LAN and relay) so paired devices
can keep using the same domain on any network.
OpenCode Go now reads quota usage with a bearer API key from OpenCode auth.json
Removes the old workspace ID and browser cookie credential flow
Deletes legacy OpenCode Go credential files during upgrade
Adds a dedicated skill for drafting user-facing unreleased changelog entries.
Covers both the main app changelog and the VS Code extension changelog.
Updates agent guidance to route changelog work to the new skill.
Keeps the work status panel aligned to the draft's target directory
Resolves project labels for sibling worktrees using shared session-directory logic
Adds coverage for resolving a worktree back to its registered project
MCP authorization was broken in several stacked ways. The browser return
leg landed on the SPA behind the auth gate, so the system browser saw a
login page instead of finishing; the pending-context store silently
saved nothing because its route had no JSON body parser; and the
callback-URL config write started deferring behind Apply & Restart, so
authorization ran against a runtime without the URL and dead-ended on
OpenCode's loopback listener.
The return leg is now completed entirely server-side by an
unauthenticated GET /mcp/oauth/callback that only forwards a code whose
state matches a parked context. Desktop with the local server and VS
Code switch to OpenCode's native flow over its fixed loopback port —
no config writes or restarts at all, with a one-time cleanup of the
previously written callback URL — and its completion signal drives the
page instead of blind status polling. Remote, hosted-web, and mobile
keep the server-callback flow, applying a queued callback-URL write
immediately since authorization cannot wait for a manual restart.
Also: a server queued behind Apply & Restart now shows an Awaiting
restart badge and explanation instead of connect/reauthorize buttons
that can only fail, and Reauthorize is offered only while the server is
actually connected.
Dialogs and settings window now use the standard background color
Removed dialog from the glass utility styles
Preserves existing glass effects for other surfaces
Relay demand now counts the authoritative transport signal: a request
arriving through the tunnel permanently marks the client usesRelay, and
hasActiveRelayClients also accepts lastTransport === 'relay'. Store read
failures no longer masquerade as no demand, so reconcile can't persist
enabled=false and sever paired devices on a transient error.
Drops the macOS vibrancy toggle, IPC, and related settings copy
Updates dialogs, popovers, tooltips, and dropdowns to use shared glass styles
Adds sticky header fade behavior to model picker and sidebar lists
Adds a work-status panel beside the transcript. Context fill, model and
cost, todos, running subagents and the permission requests blocking
them, branch and working-tree state, MCP servers, pinned messages and
context sources were scattered across the header, the composer and the
context panel — a blocked subagent was reported nowhere at all. The
panel reads them from live channels rather than persisted history, and
becomes an overlay where the chat is too narrow to seat a column.
It is on by default, including for existing installs. Because it now
carries these readouts, the desktop header and composer drop the ones it
duplicates: todo and changed-files chips, usage and MCP tabs. VS Code
and mobile keep theirs — neither hosts the panel.
Fixes MCP authorization, which was broken from the panel, invalidated by
a directory switch through a redirect URI that encoded the working
directory, and left the desktop app in the background because browsers
will not follow a custom-protocol link without a user gesture. The
settings page no longer asks the user to understand the MCP spec before
adding a server: one field takes the command or the link, with the kind
inferred and a visible override, and client-registration fields appear
only when a server actually asks for its own credentials.
Also: skills load from the panel instead of only when the composer's
slash autocomplete opens; the header button names the current instance
rather than falling through to the word "Instance" for relay hosts.
Three new optional UI settings keys, all migrated. No change to stored
MCP server configuration.
The ping has no pong-timeout on either side — it exists only to keep
intermediaries from idling out the socket. 45s stays safely under the
relay host-data idle reaper (90s) and common proxy read timeouts (60s),
while halving the events that wake the relay Durable Object during an
open session.
The suite mocks `session-actions` by listing its exports one by one, and had
fallen behind `unarchiveSession`/`unarchiveSessions`. `session-ui-store` imports
both, so the file threw on import and ran zero tests — the draft auto-accept and
canonical-worktree-directory guarantees it covers were unprotected, and the
report looked almost like silence rather than a failure.
The spinner ran a CSS animation on every active row for the whole turn,
repainting a composited layer at frame rate. Rows now carry a static dot —
primary while running, info while unread — and the metadata slot on the right
shows how long the turn has been going, updating once per second in the dot's
colour. The counter is the motion the spinner used to provide, at 1 fps.
Collapsed groups, folders and projects take the dot only, since one counter
cannot speak for several running turns.
Elapsed time is measured client-side because SessionStatus carries no
timestamps, and starts are persisted so a reload resumes the same count. Two
rules keep that honest. Only a liveness stamp — refreshed while a session is
observed active, stamped as the page hides, and compared against the page's
navigation start so a slow bootstrap is not charged to the absence — and a 90s
adoption window may expire a record; a snapshot that cannot yet see a session
is not evidence its turn ended. And a busy event is never read as a turn
boundary, because OpenCode republishes busy at every step of the agent loop, so
after a reload one of those repeats normally beats the first status snapshot.
Idle and error events do end a turn, and retire the record with it.
Snapshot reconciliation walks the running turns and asks whether the snapshot
covers each one, rather than being handed everything it covers: only a live
start can settle, so the pass scales with timing work instead of with the
directory's session list, and allocates nothing per poll.
Also applied to the mobile sessions sheet and session switcher. The shared
duration ticker moves to hooks/ now that it has a second consumer.
Follow-up to #2629, which stopped the walkthrough from comparing against a
branch that does not exist. The same guessing, and the same near-misses in how
the answer was applied, were left elsewhere:
- The default branch travelled as `rootBranchHint`, whose documented meaning is
"the branch the project root worktree is on". It gets its own option, because
a parameter that means two things is one the next caller gets wrong.
- A candidate equal to the branch being compared is skipped. In a plain checkout
the root hint *is* the current branch, so it won every time and produced a
comparison with itself; the repository default now wins there.
- The Changes and pull-request surfaces read the default branch too. A pull
request opened against a branch that does not exist is a worse failure than a
walkthrough that will not generate.
- `hasResolvableBaseBranch` matched `origin/feature/main` for a base of `main`,
passing the check and then failing the comparison it exists to prevent.
- `getRangeDiff` promoted only `origin/<base>`. A base carried by any other
remote stayed a bare name, which git resolves against refs/heads and nowhere
else, so it failed exactly as before.
- `getBranches` dropped every branch of a remote that did not answer, turning
"we could not ask" into "these branches are gone" — offline, that silently
removed comparisons that work fine against local remote-tracking refs.
- A remote with no `remote/HEAD` is asked once with `ls-remote --symref` rather
than falling back to the guess this data exists to replace.
The `defaultBranches` contract was documented under the status response; it
belongs to the branches response, which now has a section of its own.
- Add switch_context_surface shortcut (default Cmd/Ctrl + 1..9, 0 for the 10th
surface) that opens/closes/switches context panel rail surfaces by their
visible order, configurable and persisted in Settings -> Shortcuts.
- Show order-number badges on rail icons while the modifier is held >500ms;
dismiss on release, blur, or a number press until the next press-and-hold.
- Remove the legacy mod+2/3/4 (diff/terminal/git) and switch_tab_1..9 bindings
so numbered surface switching goes only through the new mechanism.
- Replace the help-dialog 'Switch Project' row with the surface-switch row and
update the shortcuts footer/header icons to the command icon.
OpenCode's authorize response reports how the client must finish: `code`
expects a pasted code, while `auto` requires the client to call
oauth/callback immediately and hold it open — upstream blocks in there
polling for the device code or waiting on its loopback redirect, and only
that call persists the credential. Every auth plugin OpenCode ships uses
`auto`; none use `code`.
The page implemented only `code`. It opened the browser, showed a paste
field no provider can fill, and never called back, so a successful sign-in
stored nothing and the app sat unchanged. Authorization now drives the UI:
`auto` chains straight into the callback behind a waiting state with a
cancel, and the paste field appears only when a provider actually asks
for a code.
Two smaller failures shared that surface. Prompts were never collected,
which put GitHub Copilot Enterprise out of reach entirely, so a method
that declares them now asks first and passes the answers to authorize.
Device codes are also recovered from the instructions text, where they
actually live — the old code read fields the API does not return, so the
copy button never appeared.
The callback is exempt from the ordinary proxy deadline and gets a
15-minute budget, bounded by the shortest upstream expiry we know of.
A human sign-in with 2FA does not fit in four minutes, and expiring it
turned a completed login into a 504.
A server without these routes does not answer 404 with JSON. The unmatched
/api path reaches the OpenCode proxy, and OpenCode serves its embedded web UI
for anything it does not recognise — HTML, status 200 — so a client newer than
its server parsed a web page as JSON and put "Unexpected token '<', "<!doctype"
in the panel, naming neither the cause nor the remedy.
The client now checks the content type before parsing. A non-JSON answer on 2xx
or 404 blocks with "this server is older than the app, update it and refresh".
A non-JSON 5xx keeps its own failure: a server that answered badly is not a
server missing the feature, and sending that user to upgrade chases the wrong
thing.
The "Critical" pill was painted in the status-error colour, so a stop marked
because it drives the change read as a severity reported against the code —
the one thing this feature never does. It is now "Key change", carries its
emphasis with weight and an outline rather than a status colour, and both tags
state their meaning in a tooltip. The panel links the guide from its header,
and the guide gained a section on what the tags mean and what they do not.
Also corrects two German strings that translated the noun "stop" as the verb.
`prompt_async` answers 204 as soon as OpenCode forks the run and reports
every later failure only on the session event stream, so an unusable
model, agent, or variant produced a session with no message while the
result still claimed `promptDispatched: true`.
Validate an explicitly requested model, agent, and variant against the
directory's own agent and provider lists before any session, worktree, or
goal is created, and confirm a new user message actually reached the
session before reporting the dispatch. A failed or empty lookup never
turns a valid selection into a rejection.
Adds a new info-styled button variant for consistent secondary actions.
Updates mobile sessions sheet actions to use the shared Button component.
Cleans up the mobile footer buttons with the shared sizing and styling system.
Review found the owning documentation still describing the behaviour this
branch replaced, in one case stacked directly above the new docstring saying
the opposite. Holding a session proves containment, not ownership, so every
text that called store membership the authoritative mapping was actively
misleading for the module whose wrong answer misroutes every send.
Corrected in the module docstring, the resolution module's precedence
description, the sync-refs helper it points at, and the sync DOCUMENTATION.md
table and rules.
The debug report built its authoritative value membership-first, so for exactly
the scenario this branch fixes it reported the parent directory and could raise
a source-disagreement alert while routing was in fact correct. It now uses the
same record-first order as the resolver.
The CLI timeout comment claimed the wait and provisioning windows were additive
while the code took the larger of the two. The server provisions the worktree
inside session creation, before it waits for the session to go idle, so they do
run in sequence: the windows are now summed and the tests pin both cases.
Softens the dark surface and syntax background tones
Updates foreground, selection, and cursor contrast
Keeps markdown, chat, and tool text aligned with the new palette
Creating a session with a worktree reported "Request to /api/openchamber/control
timed out after 4000ms" while the worktree was in fact created, leaving the user
with a failure message, a real worktree, and no session id. Reported alongside
worktree creation appearing to take forever.
The client HTTP timeout was extended only when the caller asked to wait for the
session. Provisioning a worktree is slow on its own: it runs git against the
repository and prepares a new directory. Measured on a cold path immediately
after a restart it takes about four seconds, which lands exactly on the four
second default and explains why this failed intermittently rather than always.
A warm run finishes in well under two.
The timeout now follows the work being requested rather than only the wait
flag, and covers whichever of the two windows is longer. The server always
completed the operation, so nothing about the outcome changes: only the client
stops abandoning it.
Verified by creating a worktree on the cold path immediately after a restart,
which previously failed here: 4004 ms and 1376 ms, both reported ok.
A session created in a git worktree while the client was already running did
not render: the message list stayed empty while the prompt and the assistant
reply were both present in the session, visible on any fresh load. Reported as
prompting in a worktree sometimes not working.
Ownership was read from which child store holds the session. That is
containment, not ownership. A project's session list includes the sessions of
its worktrees so the sidebar can group them, so the parent repository holds
worktree sessions too, and whichever store bootstrapped first won. Captured
mid-failure, the two signals disagreed outright:
owningDirectory /repo <- parent, merely holds it
recordDirectory /repo/.worktrees/feature <- the session's own directory
The parent won, so every fetch was addressed to a directory that does not own
the session, the session id resolved to undefined there, and the requests
failed as /api/session/undefined in a retry loop. The session's own record is
now believed; store membership remains the fallback for a record that carries
no directory.
This also explains why the previous commit alone was not enough: settling the
guessed directory adopted this same wrong value and then cleared the guess,
which prevented any later correction.
Verified against the reproduction rather than by reasoning. Before: three of
four runs never rendered. After, on a clean build with the instrumentation
removed: three of three rendered the reply live, each routed to its own
worktree. Tests cover ownership disagreeing with containment, plus both
directions of the guess promotion.
Adds an Electron install check that repairs incomplete or wrong-architecture binaries
Runs the check during root postinstall and before electron dev startup
Adds tests and docs for the new ensure:electron workflow
Selecting a session whose directory this client has not indexed yet routes it
through the active directory. That is a deliberate, documented guess: it keeps
routing usable while the owning store bootstraps, and it is excluded from both
the resolver and persistence.
Nothing settled the guess afterwards. `setSessionDirectory` performs exactly
that promotion, but only confirmed destinations call it — a completed move or a
worktree this client created. A session whose directory the client learned about
later, such as one in a worktree created outside this client, kept the guess
forever: every message fetch was addressed to the parent repository, which does
not own the session.
Captured for such a session before this change, with the session already
indexed and its owning store known:
routedDirectory .../worktree/feature
currentSessionDirectory /repo <- guess, never settled
opencodeClientDirectory /repo
conflict selected -> /repo
and after:
routedDirectory .../worktree/feature
currentSessionDirectory .../worktree/feature
opencodeClientDirectory .../worktree/feature
conflict null
Directory bootstrap completion is the moment the authoritative directory first
becomes readable, so the promotion runs there. It only ever promotes a guess:
a confirmed selection and a selection that has since moved on are both left
alone, and tests cover both directions.
This removes a real routing split-brain. It does not by itself fix the reported
symptom of a session created mid-session never rendering; that remains open.
The SQLite write into OpenCode's database was the only consumer of
better-sqlite3 in the repository. Everything that existed to ship its native
binary went with it:
- the dependency in @openchamber/web and @openchamber/electron
- the afterPack hook staging better_sqlite3.node into app.asar.unpacked
- a dedicated @electron/rebuild pass (onlyModules) and its binary assertion,
so desktop packaging now runs one native rebuild instead of two
- the bundler external entry and the AppImage required-native-module check
Desktop packaging, the AppImage verification tests, and the extension bundle
were re-validated after a clean reinstall, so no stale module could satisfy a
missed import.
Creating a worktree wrote the new directory straight into OpenCode's own
project storage: the web server updated `storage/project/<id>.json` and ran an
`UPDATE project SET sandboxes` against `opencode.db` through better-sqlite3,
and the VS Code extension wrote the same JSON.
Both wrote behind the back of a running OpenCode process. OpenCode registers a
sandbox through `project.addSandbox`, which emits a project-updated event; a
direct row write emits nothing, so a worktree created while OpenCode was
running stayed unknown to it until a restart. The SQLite write also opened a
database file owned by another live process. The VS Code write was inert on top
of that: OpenCode v2 reads sandboxes from the database, not from that JSON.
Registration is not ours to perform. OpenCode records a worktree as a sandbox
itself when an instance boots for that directory, and filters entries whose
directory no longer exists when reading them back, so removal needs no
counterpart either. The only consumer on our side, the project seed in
sync/bootstrap.ts, already falls back to `project.current()` when the seed is
absent; the worktree list itself comes from git, not from sandboxes.
Reported symptom this targets: a worktree created after `openchamber restart`
never answers prompts, and restarting OpenChamber makes it work. Not reproduced
locally, so this is not confirmed as the cause.
A queued message is removed from the queue only after its send resolves,
so between dispatch and resolution it stays visible to every reader — and
a composer submit merges the whole queue into its own send. Over a relay
that window is seconds, long enough to deliver the same message twice.
The queue now tracks which entries are awaiting the server. Dispatchers
skip them, clearQueue retains them so the pending send can still remove
or restore its own entry, and the flag is not persisted because a restart
has no in-flight sends.
A prompt whose response is lost after the request left the client may
already be running server-side. The relay tunnel reported those failures
as plain text errors ("stream aborted by host", "relay keepalive
timeout"), which matched none of the patterns in isAmbiguousSendFailure,
so an accepted prompt was rolled back and the message queue re-sent it —
two independent AI responses for one user message (#2425). Direct
connections never hit the path.
Transports now tag dispatched-but-unconfirmed failures and the classifier
reads the tag before falling back to status/text heuristics. Confirmation
waits for the connection to actually return (bounded) and retries with
backoff instead of two attempts 150ms apart over the just-broken tunnel.
The longest-task calculation spread every recorded task into Math.max, which
overflowed the call stack on traces carrying hundreds of thousands of tasks —
the same failure already fixed for collecting trace events.
Adds fixture variants that keep an identical transform animation and vary only
its surroundings — inside a button, under a filtered, clipped, blurred,
transformed or faded ancestor — plus the repository's own spinner overrides
isolated piece by piece. Adds --filler, which pads the page with static
elements, because a variant that costs nothing on a small page is not proven
free in a real document.
All of them measure zero style recalculations per second, including at 15,000
filler elements, which rules out ancestor context, the custom keyframes,
transform-box and document size as explanations for the cost the same spinner
shows inside the application.
Adds `bun run profile:animation`: it serves an isolated fixture and measures
each animation variant directly, so comparing techniques takes seconds instead
of an application rebuild plus a streamed response.
The result is unambiguous and does not vary with element count, measured from 1
to 32: transform, opacity and filter cost zero extra style recalculations, while
the individual rotate property, background-position, border-color and box-shadow
each recalculate style 60 times a second, and geometry properties add layout on
top. Notably `rotate: 360deg` is not a cheap synonym for
`transform: rotate(360deg)`, and will-change, wrapper elements, containment and
stepped timing do not make a non-composited property cheap.
`scripts/perf/DOCUMENTATION.md` documents all four capture commands, how to
stand up a production build to measure against, how to read the artifacts, the
validity guarantees the scripts enforce, and the methodology rules, so this can
be handed to an agent as the entry point for measuring performance. It is linked
from the root guide's documentation anchors.
The theme skill gains an animation contract carrying the measured table, and the
performance skill points at the tooling documentation.
Idle and streaming cost both depend on how much of the sidebar is mounted, so
the scenario setup is now shared. --expand-projects seeds the persisted collapse
state; --expand-sessions clicks every "Show more sessions" control, which
cannot be seeded because pagination is component state. Both run before the
measured window, so it stays input-free. Session expansion must run after the
sidebar has populated, not straight after the load event, or the controls do
not exist yet.
Also replaces a spread push over collected trace events, which overflowed the
call stack once a populated sidebar produced chunks of hundreds of thousands of
events, and the equivalent spread in the heap-maximum calculation.
A background session must not make the session on screen expensive. The
streaming profiler can now display a different session than the one it prompts,
which measures exactly that. The rendered-stream validity check is skipped in
this mode, because rendering nothing is the expected result.
Every wrong turn in this work came from trusting a number whose validity had
not been established: a throttled renderer reporting zero rendering work, a
trace category that was never enabled reporting zero long tasks, a scenario
that rendered nothing reporting a perfectly quiet profile, and an 'after'
measured without a matching 'before', which made a no-op change look like a
fix.
The performance skill now puts measurement validity ahead of measurement,
requires a baseline from the unchanged build through the identical scenario,
directs native work to the timeline trace rather than the sampler, requires
unvalidated changes to be reverted and recorded as rejected, and says when to
stop optimising a path that is already inside budget. It also points at the
repository's capture commands.
The sync skill gains the invariant behind the cache-thrash loop: an entry
acquired during render but protected only after commit is unprotected for the
whole render pass, capacity should be a soft target, eviction must not run on
the acquisition path, and raising a limit relocates a cliff instead of removing
it.
Compositing shows up in a trace as Layerize, Commit and PrePaint with no
indication of what caused it. The streaming profiler now snapshots
document.getAnimations() mid-capture and reports the running animations by
keyframe and target, which names the elements keeping the compositor busy
instead of leaving the trace to be guessed at.
Expanding a project with more worktrees and sessions than MAX_DIR_STORES put
the sidebar into an endless request loop (#1472).
Every sidebar row calls ensureChild during render, but the pin that protects
the directory is only taken in an effect after commit. ensureChild marked the
directory and ran eviction synchronously, so directories that were actively
rendering looked unpinned and were disposed. The next render recreated them
with a loading status, which issued another bootstrap request, and the cycle
repeated for as long as the project stayed expanded.
Raising the limit only moves the cliff, so the limit is now a soft target
instead: a directory touched within a grace window is never an overflow
victim. A burst of live directories overflows the cache briefly rather than
thrashing, while idle-time eviction still bounds it. Eviction is also coalesced
into one deferred pass per tick, so a render that mounts many rows no longer
sorts and scans every directory once per row, and a whole commit's pin effects
settle before anything is considered for disposal. Releasing the final consumer
stays synchronous, since that is an explicit lifecycle edge.
The idle profiler gains --expand-projects to reach this state.
Not yet verified end to end: reproducing the loop needs many worktrees under
one project, which this development environment does not have.
A CPU sampling profile attributes native work to `(program)`, which during
streaming accounted for three quarters of all busy time and said nothing about
where it went. The timeline trace names that work, so the streaming report now
lists total and maximum time per trace event, skipping container events whose
duration already includes the work below them.
`getRuntimeKey` keys caches, stores, and persisted state across the whole UI,
so it runs on store reads, event handling, and render paths. Until the runtime
endpoint is explicitly initialised, every call re-derived the key by trimming
two injected globals and constructing three URL objects.
In a streaming capture this made `readInjectedLocalOrigin` the single most
expensive application function: 315 ms of self time, 12% of all main-thread
busy time. After the change it does not appear in the profile at all, and the
same capture went from two long tasks to none, with the longest task dropping
from 210 ms to 47 ms.
The key depends only on the active API base URL and two injected globals, and
`switchRuntimeEndpoint` writes the injected API base URL at runtime, so the
cache is validated against the raw untrimmed values rather than memoised
outright. That comparison allocates nothing and still recomputes as soon as any
input changes. Tests cover both directions, including an operation-count
assertion that repeated calls construct no URLs.
The streaming profiler also reports output-normalised metrics, because response
length varies between runs and makes per-second totals incomparable.
Adds `bun run profile:session`: creates a session, opens it in a real
browser, dispatches a prompt through the supported `openchamber session` CLI,
and records until the session reports itself idle. No input is synthesised, so
everything captured is the app reacting to its own event stream.
Streaming is judged by responsiveness rather than totals, so the report leads
with the long-task distribution, style recalculation and layout rates, frame
production, and the application's own stream counters.
Two failure modes are detected rather than reported as clean results. A session
belonging to a directory the browser is not viewing renders nothing and
produces a perfectly quiet profile, so the run verifies both new message
elements in the DOM and message-list render counters. And `RunTask` is only
emitted under the disabled-by-default timeline category, so a capture without
it reports zero long tasks; the missing-task case is now called out instead of
being shown as zero.
Metric helpers are shared with the idle profiler.
Idle cost depends on which surfaces are mounted, so the profiler needs to
reach those states without a human driving the UI.
`--panel <mode>` opens the context panel by seeding the persisted store the
app reads on boot, using the same tab identity rules as `useUIStore`.
`--then-tab <name>` navigates through the router after settling, which leaves
already-mounted surfaces mounted and measures what a screen keeps doing once
the user has moved on. Both drive real application state instead of
synthesising clicks, so the recorded window stays free of input-driven work.
Adds `bun run profile:idle`: a fully unattended capture of what OpenChamber
does while nobody interacts with it. It reports main-thread busy time, style
recalculation and layout rates, DOM node and listener growth, heap trajectory,
a CPU sampling profile, and per-call-site attribution of timer, animation
frame, and observer work.
Chrome throttles timers and stops producing frames for occluded or backgrounded
windows, which silently reports an idle-looking renderer regardless of what the
page schedules. Launch flags now disable that throttling, and every run measures
frame liveness so a throttled capture is reported as a warning rather than as a
clean result.
CDP launch and client code is shared with the existing browser profiler.
German was added to the interface but not to the walkthrough's own language
list, and nothing failed: the picker offered Deutsch because it is built from
the interface locales, the server resolved the tag to English, and a German
reader paid for a walkthrough written in English while the picker still said
Deutsch.
The two lists cannot be one — the server cannot import from packages/ui — so
a test reads i18n/runtime.ts and compares them, in both directions and through
normalizeLanguage. Drift this quiet needs a test rather than vigilance.
Session directory resolution had no precedence contract: the selection-time
directory short-circuited every lookup, and a persisted runtime value was
consulted before the authoritative record. A worktree session selected before
its directory store bootstrapped kept the active-directory fallback, and that
guess was persisted, so it survived reloads and restarts.
Directory resolution now lives in one module and orders sources by whether the
server confirmed the path, not by whether the value is local or synced:
authoritative (the child store that holds the session) > server-confirmed
selection > worktree attachment/metadata (the requested path, pre-canonical) >
remembered. A guessed selection is no longer persisted, remembered, or ranked.
Chips read the same resolution the composer used, so queue keys cannot diverge.
Queued auto-send could strand an item indefinitely: backoff, missing send
configuration, and the recent-abort window all returned without scheduling a
wake-up, so the queue only retried when an unrelated status or directory change
re-ran the effect. A retry scheduler now wakes it at the earliest known time.
A rejected send rolls the optimistic message back while the composer stays
silent for transport failures, which makes it indistinguishable from nothing
happening. Failures are now recorded to a bounded in-memory log surfaced in the
About diagnostics report, alongside a directory-resolution breakdown, plus
__opencodeDebug.diagnoseSessionDirectory() and getRecentSendFailures().
Prompted by a report of worktree prompting silently failing. That failure was
not reproduced locally, so the diagnostics are what will identify it.
A guided explanation is only useful in a language the reader reads, so the
panel header gets a language picker alongside the model one, defaulting to
the interface language. Like the model, it is request state rather than a
setting: the language travels with the read and the generation, and the one
a walkthrough was written in is stored with it, so reopening a review
describes what is there instead of what a fresh one would be.
Only prose is translated. Hunk aliases resolve back to hunk ids and
icon/importance are validated against fixed English values, so a translated
one would be dropped by the normalizer — silently losing an anchor or a
style. Identifiers and paths stay as they appear in the code.
The language is part of the cache key, and a read now asks the cache for the
exact request it was given before falling back to the pointer. Without that
the panel answered a request to switch languages with the text it already
had, leaving the other language unused in the cache.
Alongside it:
- The answer budget is derived from the resolved model instead of a flat 24k.
That number was the same for a 64k-context model and for one that admits to
384k output tokens, and on the latter it was the only reason generation
failed: the model spent the whole allowance reasoning and returned nothing.
It is now min(96k, max(24k, a quarter of the context)) capped by the
catalog's output limit, decided once so the input reserve and the request
cannot drift apart.
- A read no longer offers Cancel. It is a few hundred milliseconds of git with
nothing to cancel, and the button flickered on every model or language
change. When the panel is showing a fallback, a banner names what is on
screen versus what was asked for — only once the read has settled.
- The header keeps one 32px control height and drops its labels below 680px
instead of squeezing them to two letters and an ellipsis.
Docs and module documentation updated in every locale.