Review follow-up:
- Reject loop files whose frontmatter name exceeds MAX_TASK_NAME_LENGTH
(80): task names are clamped at storage time, so a raw name longer than
the limit could never match the stored task identity. The file is treated
as malformed (definition: null) instead of creating an unreachable
definition; MAX_TASK_NAME_LENGTH is now exported from project-config.js
and shared with loops.js.
- Surface loop-sourced tasks in the scheduled-tasks dialog: tasks carrying
loopFile show a 'Managed by loop file <path>' note, and the enable
toggle / edit / delete actions are disabled with an explanatory tooltip,
since the file remains authoritative and would revert any such change.
run-now stays available. New locale keys added to all 11 message files
(i18n parity test enforces exact key sets).
- ScheduledTask type gains an optional loopFile field (additive, unknown
to older clients).
Closesopenchamber/openchamber#2627
Listing a directory through a workspace symlink was returning realpath
entry paths. The file tree then rejected nested expand toggles because
those paths fall outside the workspace root.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
The desktop ContextPanel captured Escape on the panel aside and closed
the whole pane before ghostty-web's bubble-phase listener could forward
the key. Skip closing when the event target is inside the terminal so
apps like Vim can leave insert mode.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
The badge PR inverted showActivityDot to !== 'git', so editor/terminal/diff
picked up the blue activity dot whenever git had changes. Git already shows
a numeric badge; other surfaces should stay quiet. Also split the count
aria/tooltip strings into singular/plural keys.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
When a managed OpenCode process dies mid-turn (crash, health-check
restart), the persisted turn never settles: the trailing assistant
message has no time.completed and its tool parts stay pending/running
forever — the server never finalizes them (anomalyco/opencode#19023).
The existing settle-triggered tail refresh refetches the same stale
records, so the UI kept running tool timers and working styling
indefinitely (#2577).
Now, when a session is authoritatively settled (session.idle/
session.error event, or an authoritative status snapshot lowering a
previously busy session) and the trailing assistant message is still
unfinished with active tool parts and no pending question/permission,
the orphaned parts are finalized locally as error/"Interrupted" with
an end time — the same shape OpenCode itself writes for cancelled
tools. The mark is gated on an explicit idle status (absent status is
"unknown", never judged), never applies while busy (including
question/permission waits), and a later terminal event or refresh
supersedes it while a stale running refresh cannot regress it (the
reducer and materializer already preserve final statuses).
Fixes#2577
Adds a per-session pending-question badge to sidebar rows, driven by the
live directory-store question state through a dedicated per-session
subscription channel so unrelated streaming never re-renders rows.
Collapsed parent rows roll up pending questions of hidden descendants
from their owning directory stores without bootstrapping them. Question
state is cloned on session delete/archive so badges clear when sessions
disappear. Adds the questionChangeCallbacks sync performance counter,
i18n keys for all locales, and unit tests for the subscription channel
and scope selection.
Fixes#2634
Replaces the plain activity dot on the context panel rail's Git button with
a numeric badge of the changed-files count from the git store status, so the
count is visible at a glance without opening the Git surface. Large counts
cap at 99+ to keep the pill within the 36px button. The badge is reflected
in the button's accessible label and the hover tooltip.
Fixes#2364
After the add-to-context (context pin) action completes successfully, move
focus back to the chat input so the user can keep typing immediately. Uses
the existing focusChatInput helper and the requestAnimationFrame refocus
pattern already used by the model/agent selectors.
Fixes#2447
Answering a question tool (or a permission prompt) could leave the session
permanently stuck on "asking question": resolveDirectoryForBlockingRequest
returned the containing child-store key, which only proves containment.
For a worktree session (or any session whose record is grouped under a
parent project store), the reply was addressed to the parent directory's
OpenCode instance, where the pending request does not exist - the server
answered QuestionNotFoundError, the local request was removed, and the
trailing question-tool part stayed running with no recovery until Stop.
Resolve the directory from the request's own session record (server-
confirmed ownership: session.directory, then project.worktree) before
falling back to the containing store key. When a reply/reject comes back
not-found, also enqueue the settled-running-tool tail materialization so
the tool part converges to the server's actual state instead of leaving
the UI stuck.
Refs OPE-236
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.
Synthetic subagent-completion nudges were treated as the latest user model
choice and rehydrated the agent default, while setAgent preferred the agent
pin over the session override. Skip synthetic prompts for restore, preserve
manual selection-store overrides, and prefer session agent models in setAgent.
Closesopenchamber/openchamber#2404
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
The status-info classes were winning over muted disabled styles, so the
button still looked actionable when no model was selected.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
For OAuth-only providers like Cursor, open the auth panel when
credentials are missing and omit the models list until auth/env
credentials are present so placeholder catalog entries are not shown
before login.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Treat an empty allowedProviderIds list as allow-none, disable Generate
when no usable model is selected, and mute the button styling so it
reads as unavailable rather than actionable.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Only show the API key credential UI when a provider declares API auth
(or auth methods are still unknown). OAuth-only plugin providers such as
Cursor now show Connect/OAuth only, load auth methods on reconnect, and
skip an empty models section until models are discovered.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Do not present a provider without a login as the selected walkthrough
model, and grey out Generate when readiness is false instead of showing
a login-error blocker or raw auth banner.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
When the walkthrough small model resolves to a provider with no usable
login, readiness was still ready and generate returned a raw 500 message.
Refuse up front with no-provider-login and surface a blocker instead.
Closesopenchamber/openchamber#2607
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Archived sessions had no way back to the active list: the only available
action was "Delete permanently". Add restore per session (sidebar context
menu, Archive page row) and in bulk (sidebar selection bar).
The OpenCode server cannot clear time.archived over HTTP — session.update
only applies the field for a finite number, so an omitted key is a no-op
and null is silently ignored (verified against opencode 1.18.12). Restore
therefore writes time.archived = 0: every client-side reader classifies
archive state by truthiness, so 0 reads as active in the UI, the event
reducer, and the OpenCode app/TUI.
The server's time_archived IS NULL list filter still excludes such rows,
so the global session cache no longer issues an archived:false request
for its active list. Full and per-directory loads now fetch once with
the inclusive flag and split client-side via splitGlobalSessionsByArchived,
which also halves per-directory refresh requests. Directory bootstrap
keeps the server filter because live child stores must not hold archived
sessions; a restored session re-enters its live store through the
authoritative session.updated event.
unarchiveSession/unarchiveSessions follow the archiveSession contract:
wait for server confirmation before reconciling stores, runtime-guard
every reconciliation, preserve partial batch results, and fail loudly
when the server keeps the session archived instead of toasting a
successful no-op.
Closes#2346
Keep the shortcut in Help/Settings only; the floating selection chip
should stay quiet so the Add to Chat action is easier to scan.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
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
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.
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.
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.