Review comments, quotes, terminal selections and annotations were consumed
at send and never put back, so reverting pulled the message into the
composer without the context it was sent with.
Claude-Session: https://claude.ai/code/session_01TwLFeTfBnWdvbg9XyezZQx
Clarify chat session switching is visually stable
Note GitHub account connection moved to Settings → Integrations
Update VS Code changelog wording for session switching
Adds a shared sidebar tooltip provider with delayed open and instant close behavior
Removes per-button tooltip delays so sidebar actions feel more consistent
Aligns session sidebar hover interactions with the opencode-style tooltip experience
Switching sessions moved the conversation on screen before the swap: the
composer and the status chip followed the live selection and re-shaped a
commit ahead of the timeline, so the pinned outgoing chat jumped; and the
reveal effect re-ran for the outgoing session when its waited flag flipped,
hiding it a few frames before the next one mounted. The chat column now
reads one deferred session, and the reveal runs once per opened session.
The pull-request rail icon now appears only while GitHub is connected
(OAuth or gh CLI), like Linear; Linear sits after the walkthrough in the
default rail order. The GitHub account avatar and switcher leave the
header for the pull-request panel, where the walkthrough, refresh, and
account controls share one row and one height, and the account stays
visible on the panel's empty state. A manual refresh keeps its spinner on
screen long enough to read as work done.
GitHub sign-in moves from Settings → Git to Settings → Integrations →
Built-in integrations as a card before Linear; search and the connect
buttons follow it.
The scoped theme entry is now parsed by one boundary parser with a
stated invariant instead of ad hoc typeof narrowing, and the runtime
keys that mean "no instance connected" live next to the code that
produces them so a new sentinel cannot miss the theme-storage guard.
Every project-config write re-serialized normalized tasks, so a server
that shares the config file but predates a field (goal, auto-accept)
stripped it the first time any task ran. Untouched tasks now go back to
disk verbatim, a state update swaps only `state`, and only a deliberately
replaced task is serialized from the normalized shape.
Opening a file from the Git panel while a nested repository was selected
created the diff tab under the repository path, a key the context panel
never displays. Tabs are keyed by the project root; the diff surface
resolves the selected nested repository itself.
The work-status Project section now reads branch, changes, and PR from the
same resolved repository as the Git tab and names the nested folder under
the branch so the reader knows which repository the readouts describe.
The server joins discovered repository paths with the platform separator
while every other git directory key in the UI is normalized, so on Windows a
discovered repository never matched its own selection or the root prefix the
picker strips. Parse the route's response at the boundary and normalize each
path. Note in the store docs that worktree bootstrap and session machinery
stay keyed on the project root while a nested repository is selected.
Text-to-speech picked one voice regardless of what language a reply was in.
A dependency-free language detector (script, marker letters, function words)
now decides the language of the whole message once; with the new
"Match the voice to the language of the text" setting the local provider
switches to a catalog model for that language (Kokoro zh/en and Piper models
for 12 languages, downloaded on first use like the existing model) and macOS
say switches to an installed voice whose locale matches. The local voice
picker lists voices of every installed model, and the settings show which
language models are on disk.
The Ukrainian Piper medium build is a character-level model that sherpa-onnx
turns into noise, so the espeak-based Lada build is used instead.
Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
* feat(linear): start sessions from Linear issues
Authorize a Linear workspace on this OpenChamber server, map teams to
projects, attach an issue from chat, start a session or worktree from an
issue, and post started/completed/failed comments that open the session.
Hidden in VS Code.
* feat(linear): connect more than one Linear workspace
Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree.
* feat(linear): add a right-hand issues panel
Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel.
* feat(linear): open issues in the rail and filter by Linear status
The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations.
* fix(linear): use stable OAuth callback broker
* fix(chat): preview Linear issue attachments
The context switch missed linear-issue, so tsc treated the preview helpers as incomplete.
* fix(ui): restore Linear i18n parity and the #2903 sync harness
Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext.
* fix(linear): drop changelog hunks and close review races
Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved.
* fix(linear): tint active issue filters and clear them in one click
* fix(markdown): read escaped brackets as text, not display math
`\[...\]` is display math in LaTeX and an escaped bracket pair in
CommonMark. The block tokenizer claimed every `\[`, so prose like
`[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a
centered formula and the block token split the paragraph, tearing the
link into three pieces. Linear, GitHub and any other source that escapes
brackets the way CommonMark requires hit this.
Display math now has to own its line — `\[` starts one and `\]` ends
one. A formula on its own line still renders; `\[` mid-sentence stays an
escape, which is what CommonMark says it is and what prose almost always
means. Inline `\(...\)` keeps the same ambiguity, but inline math is
legitimately mid-sentence, so there is no position to judge it by.
Covered by regression tests, including the verbatim comment body that
surfaced this.
* feat(linear): make session status comments opt-in and public-only
A status comment lands in a Linear workspace the whole team reads, and
the link it carried pointed at whatever origin started the session —
usually loopback or a LAN address. Everyone but its author got a dead
link, and nobody had agreed to the comments in the first place.
Comments are now off until the user turns them on in Settings ->
Integrations -> Linear, and the check lives on the server: the event hub
posts completed and failure without going through the interface, so a
client-side gate would not hold. When the resolved origin is not
publicly reachable the server posts nothing at all rather than a link
only its author can open; `isPublicSessionOrigin` rejects loopback,
private LAN, carrier-grade NAT, link-local and single-label hosts. The
desktop deep-link origin is gone with it, since no one else can follow
one either.
The comment body also dropped the session title. It repeated the issue
the comment already sits on, and issue titles routinely carry brackets
("[Bug] ...") that broke the markdown link. The body is now one short
link, and `sessionTitle` is gone from the route, client and types.
Also caps the dedupe file at the newest 500 sessions; it grew forever.
* fix(linear): match the pull request panel and clear review findings
Comments in the Linear panel now render as the same avatar timeline the
pull request panel uses, with the shared time-format preference instead
of a raw locale string. Comment authors carry `avatarUrl`, which the
GraphQL selection was not requesting.
Review findings from the same pass:
- `status-runtime.js` hand-rolled `typeof` narrowing and failed the
vendored anti-slop lint; it now parses through `parse.js` like every
other file in the module.
- `useLinearAuthStore` turned any failed request into `connected: false`
with `hasChecked: true`. Since the rail icon, the composer entry and
the worktree option all gate on `connected === true`, one network blip
hid Linear for the rest of the session, and Settings only re-checked
when it had never checked. It now keeps the last known status and
leaves `hasChecked` false so the next caller retries.
- `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`,
shipping in the main bundle although its rail icon stays hidden until
a workspace is connected. It is lazy now, like `GitView`.
- Dropped dead code: the unused port helpers left over from the loopback
callback, two re-exported default values nothing read, and a redundant
export in `linkedIssues`.
- Integrations is no longer badged beta.
* test(ui): provide sync runtime context in issue-2903 harness
edfc9779c (perf(chat): make session switching feel instant) rewired
useDirectoryStore and friends from the system context to the runtime
context, but this harness only rendered SyncContext.Provider, so the
render phase threw 'useSyncRuntime must be used within <SyncProvider>'
and every PR run since failed this file.
Mirror SyncProvider's own nesting: render the runtime context (read
from its globalThis registry key) inside the system one, with a
currentDirectory source matching the new CurrentDirectorySource
contract. Also drop the chained globalThis type assertions in favor of
one documented cast.
Test-only change; no runtime behavior affected.
* fix(desktop): pair close-button hover with solid error red and its foreground
The classic window-control close button hovered with the
--status-error-background banner wash but colored the glyph with
--status-error-foreground, which each theme authors as the contrast
color for the solid error red (the --destructive pairing). On the wash
the glyph loses contrast in both modes - near-black on muted dark red
in dark themes, white on pale red in light themes - and the dark-mode
wash reads as a muddy saturated red.
Hover now uses the solid --status-error with its authored foreground,
matching the destructive button pairing and the Windows caption-button
convention.
* chore: re-run PR review bot (evidence added at HEAD)
* fix(header): remove right-edge gap before close button with custom window controls
The header root already applied pr-0 for frameless chrome with
right-side controls, but webWindowControlsOverlayStyle set an inline
padding-right on the same element, which overrides the class. In
Electron (frame: false, no titleBarOverlay) the WCO right inset is
always 0, so the close button sat 12px from the window edge and the
top-right corner did not trigger close.
Skip the inline style for the frameless + right case so the class
governs; the browser window-controls-overlay path keeps its padding
and inset reservation.
* fix(desktop): inset right-side traffic lights from the window edge
The header flush-edge fix (pr-0 for frameless + right controls) also
pulled the traffic-lights cluster against the window edge, but the
inset is a Windows-caption convention that only the classic style
follows. macOS-style circles keep their spacing: an explicit 12px
right margin on the right-side cluster, owned by the component so the
mini-chat window matches.
* chore: re-run PR review bot (body now documents the traffic-lights inset)
A turn that OpenCode stopped could end with nothing on screen: the
session.error event was only turned into a sidebar badge, its message was
dropped (the notification expected a different shape than OpenCode sends),
and a send that was accepted but never answered looked the same as success.
- The chat shows what OpenCode reported under the last message while that
turn is the latest one, and names a user message an idle session has left
unanswered for five seconds.
- The last 20 session errors are kept in memory and listed in the status
report (Ctrl/Cmd+Shift+L, also `__opencodeDebug.statusReport()`), next to
rejected sends, the managed OpenCode process's last error and stderr
tail, and the OpenCode and desktop log file locations.
- The OpenCode health probe hits /global/health instead of a route that
does not exist, and probe URLs resolve against the page for web runtimes.
A session opened from the sidebar could land above the bottom, or show a
frame sitting lower and then snap up. The viewport was pinned before the
content was final: the recap note renders once the session record arrives
and grew the footer under the pinned viewport, and on large sessions
subagent task cards grow when their child sessions load, moving everything
above the viewport.
- The recap note holds the timeline reveal until the session record is in
memory, so it is part of the first finished picture.
- The scroll hook holds the reveal until the viewport is pinned; the reveal
itself runs once the content height has held still for two frames, with
one exact pin against the final height (bounded at 300ms).
- Sitting on the end of a session that is not producing output is an
invariant: content growth re-pins from a MutationObserver in the same
frame the list writes its layout, so no frame paints with the end out of
view. Output growth keeps gliding through followEnd, which now glides only
while the session is working.
- ChatContainer swaps the timeline on a deferred copy of the selection so the
active row, URL, and tab commit first. (This part had been overwritten by a
concurrent edit before the previous commit and is restored here.)
- A session whose messages are not in memory keeps the previous conversation
on screen for up to 400ms while they load, instead of flashing a skeleton
between two conversations.
- The reveal fade runs only for a session the user waited for and is 100ms;
a session that was ready at the click appears in the same frame.
- The sidebar prefetches the two rows on either side of the open session
150ms after it settles, two at a time, so neighbouring switches are warm.
Commands and skills for the composer were read from the ambient store, which
follows the project selected in the sidebar; opening a managed chat or a chat
draft never changes that selection, so the previous project's commands,
skills, and (for drafts) files leaked into chats. The autocompletes now load
and read commands and skills for the effective directory, and a chat draft
resolves its effective directory to the prepared chat directory or the Chats
root instead of the last project.
Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
Switching sessions ran as one synchronous commit: sidebar highlight, URL,
a full timeline remount with markdown re-parse, and around nine requests,
so nothing changed on screen for 150-250ms after the click.
- ChatContainer swaps the timeline on a deferred copy of the selection, so
the active row, URL, and tab commit first and the timeline renders behind
them; selection policy keeps reading the live store value.
- The message fetch starts before the selection is published.
- Sidebar rows stop re-rendering on a project switch: directory-scoped sync
hooks read the runtime context and a subscribable current-directory source
instead of the directory-bearing context; the grouping builder reads git
branches through a ref and section caches key the branches they use;
descendant ids are keyed by content. Rows per switch went from 73 to 8.
- Markdown skips the async re-render when the settled cached blocks are
already painted, and mounts synchronously once its lazy module is loaded;
the module is preloaded at boot.
- A timeline reveal gate holds a freshly opened session at opacity 0 while
any provisional markdown paint catches up (250ms cap), then fades the whole
timeline in once, so text, tools, and recap appear together.
- Switch fan-out trimmed: knowledge summary deduped, MCP status refreshed only
when stale, non-repo directories cached by the git repo check, OpenChamber
defaults cached briefly, agent memory reused for the same project, goal
text cached, PWA manifest rebuilt after the switch settles.
- Header tabs snap into the active state and keep the title at the same
height in both states.
- Prefetch on row press; composer focus moved off the commit.
`bun run profile:switch` records ack/content latency, longest task, and
requests per switch, cold and warm, and compares runs against a baseline.
Measured warm switch: ack 228ms to about 40-60ms, content 228ms to about
100-120ms.
The find_in_file keybind only opens the preview find bar when the event
target sits inside the preview container, but that container was a plain
div that never held focus, so the shortcut did nothing in read-only preview.
Both preview containers are focusable now and take focus when a Markdown
file opens in preview or is clicked, without stealing focus from a field
the user is typing in.
Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
* fix(sidebar): search the managed chats along with project sessions
The sidebar builds search data per group, and every group renders
`filteredNodes ?? []` while a query is active. Only project groups were ever
given that data, so the managed chats — which render outside any project
section — collapsed to an empty list as soon as anything was typed, however
well a title matched. The header count ignored them for the same reason.
Pass the chats group to the sections hook as a standalone group so it takes
the same search pass and joins the match count. Building it before the hook
runs keeps search-data ownership in one place instead of registering entries
from the rendering component.
No CHANGELOG entry: it is left out deliberately to keep this branch free of
conflicts with other open PRs, and is to be written at release time.
fixes#3200
* fix(sidebar): report the real search match count to the header
The header rendered whatever `SessionSidebar` passed it, and that was a
literal `0` — so "0 matches" was shown no matter how many sessions matched.
The count exists: the sections hook already computes it, but only the session
list can see it, and the header renders above the list.
Report it upwards from the list instead of recomputing it in the header, so
the number and the visible rows cannot disagree, and reset it to 0 when the
list unmounts.
fixes#3200
* fix(sidebar): keep chat-only search results on screen
The chats render inside the scroller's top content, and the branch that
handles "no project section matched" replaced the whole list with the empty
state. A query matching only a chat therefore showed "No matching sessions"
while the header — now reporting the real count — said one match, and the
chat itself stayed hidden. That is the exact reproduction in the issue; the
earlier manual check missed it because the query used also matched a project
session.
Tell the scroller when its top content holds results, and keep it on screen
in that branch. The decision moves to `sessionProjectRender` next to the
other render-selection helpers, where it can be tested without mounting the
Vite-only component graph.
fixes#3200
Context parts are marked synthetic, so a message made only of quoted
fragments was skipped as an injected message and could not be navigated
to. Such turns now appear with the same caption the bubble shows.
Opening or closing the context panel re-wrapped the timeline; the settle
handler then scrolled to LegendList's total content length, which still
carried pre-wrap sizes for unmounted rows, leaving a viewport-sized blank
tail under the last message.
Reverts #2687. In real use the injected small_model behaves poorly with
OpenCode: its internal small-model consumers and OpenChamber's own small
model are different things and must stay configured separately.
Post-merge follow-ups for #2740#2735#2734#2690#2676#2738#2684#2689#2733#2739#2462#2687#2736#2618#2697, plus three regressions
found while reviewing them:
- ctrl/cmd+digit while typing no longer switches session tabs (#2503 was
still open in practice: the guard only covered the mod+alt surface binding)
- Shiki template-call sanitizer now covers every bundled grammar, including
the js/ts aliases and embedding grammars; timed-out highlight requests are
memoized and no longer cancel unrelated in-flight requests
- settings flush on suspend uses keepalive and also fires on Capacitor
appStateChange; keeps the selected model persisted across mode switches
- remote-only branches fetch before checkout; range helpers fail clearly
- git status invalidation now fires for runtime adapters too
- settings number inputs and select triggers size in ch so they scale with
the interface font
- recent-activity timestamps tick from one list-level ticker
- Markdown preview find goes through the shared find_in_file keybind with
containment, no longer counts its own bar, and debounces observer runs
- #2676 reverted; #2524 fixed by fading the sticky header's own background
instead of overlaying the content below it
- sticky group headers in the model picker and sidebar render again
(oc-sticky-fade-scroller class restored after 9b9d7069c)
- project switcher names are left-aligned again (wrapper lost in 26dbc2f30)
- tool card quick-open icon is always visible and opens the same line as the
expanded card's button
- tautological tests replaced or removed; new oxlint findings fixed
Drop the canonical-containment 403 guard and the extra realpath(base) the
read routes (stat/read/raw/serve) had gained. Every workspace resolution
returns insideWorkspace: true and outside-file grants use
base = dirname(canonicalPath), so the guard could never fire; the flag had
no remaining reader and is gone with it. The read routes are back to the
single realpath(resolved.resolved) they had before.
Move the lexical-base fallback out of the inline header parsing in
routes.js. x-opencode-directory decoding belongs to
project-directory-runtime, so resolveProjectDirectory now also returns
requestedDirectory, the pre-realpath candidate that validated.
resolveWorkspacePathFromContext retries against it when the canonical base
rejects a path, which keeps files under a symlinked project root
addressable without a second copy of the header/query parsing.