Keep session-message loads and retries on messagesEnabled so a mounted
session-chat panel can materialize history even before the visibility
handshake, and cover the enabled-gate with the real hook.
Co-authored-by: serkraser <serkraser@gmail.com>
Busy context-panel session chats could render only the working-status row
when the iframe booted inactive or lost its visibility handshake, because
message reads shared the composer/background-work gate. Keep message
subscriptions enabled in the mounted session-chat panel so materialized
history remains visible (#2903, #2892).
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Pass initial subagent prompting settings into embedded chat URLs and views
Prevent inactive embedded chats from stealing focus on load
Treat review sessions as independent conversations when resolving prompt read-only state
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
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.
Navigation model rebuilt around two full-width drawers and a minimal
header (sessions / title-switcher / usage ring / workspace):
- Left sessions drawer: cross-project tree with live status indicators,
swipe actions on sessions (rename/archive/delete) and on group headers
(project edit / two-step close, worktree delete), reorder-only edit
mode with collapsible project cards and draggable worktrees, app-level
footer (connected instance, settings, pending web update).
- Right workspace drawer: Changes / Files / Terminal / Notes / MCP as
pill tabs (inactive tabs icon-only); panes stay mounted once visited.
The full desktop file editor serves the Files tab; read/skill tool taps
in chat open the file there at the requested line.
- Header session switcher on title tap: 10 cross-project recents with
live busy/attention indicators and project · branch metadata; the
usage ring opens a metadata overlay with an explicit loading state.
- The overflow menu is gone on phones (its destinations moved into the
drawers); iPad keeps it until its dedicated layout pass.
Correctness and continuity:
- /auth/session answers bearer-first, so a stale WebView cookie can no
longer mask a revoked device token; cold launches classify failures
fast and land on an explicit connect screen.
- Authoritative session snapshots raise frozen ordering baselines and
stale live ranks — recents stay truthful after the app slept.
- Cold launches reopen the last active session per instance (persisted
pointer, confirmed against a sessions snapshot; a user-opened draft
clears it), with a logo hold instead of a draft flash.
Also: collapsed pill composer gains the stop control; chat tool rows
share one 36px rhythm; Task subtool rows truncate; larger bottom safe
area so the composer clears big-screen corner radii; Capacitor build
hides About/Update (store updates apply there); widgets link to the
sessions drawer with a list icon; MobileApp split into focused modules;
five mobile-surface detectors unified; translucent borders normalized to
70%; all new strings translated across the 10 locales.
iPad and foldable layouts are intentionally untouched - separate next version PR.
* perf: optimize session loading and startup
* fix(chat): stabilize history prepend virtualization
* perf: unblock first session open from startup network contention
Opening the first session after app start waited seconds for its message
fetch. Three independent contributors, each measured via CDP network
capture and Chromium net-log against the packaged desktop app:
- The active-session watchdog fired an uncapped per-directory status poll
and child-session discovery burst at startup, and other subsystems
(git checks, global session pages, command/skill discovery) fanned out
alongside it, saturating the browser's ~6 HTTP/1.1 sockets per origin.
Add a shared background-network gate (concurrency 3) and route the
watchdog, poll-shaped git reads (also priority: low), global session
pages, command/skill loads, and the background update check through it.
- The packaged renderer is cross-origin to the loopback backend, so every
API call needs a CORS preflight; a few slow OpenCode-proxied requests
held the whole pool while preflights and interactive traffic queued
behind them. Lift Chromium's per-host connection cap for loopback via
ignore-connections-limit in the Electron shell.
- OpenCode initializes each directory lazily on its first request, so the
first click paid that cost interactively. Warm the last-used directory
and the three most recently opened projects right after OpenCode
readiness, sequentially and best-effort, overlapping UI startup.
Validation: new background-network tests, lifecycle warmup test, focused
store/sync tests, UI type-check and lint, dead-code report, node --check
plus electron type-check/lint, and CDP first-open measurements on the
packaged app (message fetch socket queue 5.4s -> 0.03s).
* fix(ui): keep interactive git reads out of background queue
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
Allows prompting subagent sessions without needing the parent record
Keeps subagents read-only when prompting is disabled
Covers root and subagent cases with unit tests
* refactor(ui): unify composer @mention grammar
Extract the composer's @mention rule into composer/language/mentions.ts and
route highlighting, send-time extraction, backspace-deletes-the-mention and
the file-path check through it. The rule previously lived as four separate
regexes in ChatInput.tsx with divergent cleanup, so every new reference type
had to be taught to all four.
Bracket handling is now symmetric: [ and { were accepted before @ but ] and }
were not stripped from the tail, so [@plan] resolved to the name 'plan]'.
Add characterization tests for the markdown tokenizer, which had none, to
pin current behavior ahead of the editor migration.
* refactor(ui): unify composer slash, snippet and trigger grammar
Extract /skill, /command and #snippet scanning into
composer/language/prefixTokens.ts, and the rule deciding which autocomplete a
caret asks for into composer/language/triggers.ts.
Each sigil previously had three separate implementations: one for
highlighting, one for send-time collection, and one for opening the picker.
They disagreed on the valid character set — the send-time skill scanner
accepted only lowercase names, so a /My_Skill token was painted as a command
but never collected. Scanning is now generous and membership in the command,
skill or snippet registry is the authority.
resolveAutocompleteTrigger replaces the 90-line branch chain in
updateAutocompleteState with a pure function, keeping the previous
command > skill > snippet > mention precedence.
* refactor(ui): tokenize the composer in a single pass
Add composer/language/tokenize.ts as the one entry point producing every
highlight range from the text plus what the composer knows about the
workspace. It replaces six independent memos in ChatInput.tsx that each
re-scanned the same string for markdown, fenced code, mentions, slash tokens,
snippet tokens and attachment citations.
Mentions now distinguish the reference span from the raw token: 'see @a/b.ts,'
highlights @a/b.ts and leaves the comma as sentence punctuation, while
backspace still deletes the whole token so a wrapping bracket is not orphaned.
* feat(ui): add the CodeMirror composer editor
Add composer/editor: a CodeMirror view that renders the prompt language as
mark decorations, and a controlled React primitive around it.
The composer previously painted a transparent textarea over a mirror div,
which restricted highlighting to styles that do not change glyph advance
width -- so bold and italic were impossible and the overlay was disabled
outright on mobile, where wrapped text drifted from the caret anyway.
CodeMirror owns the text and the caret together, removing the second layer.
The document stays a plain string, so nothing downstream has to serialize a
rich model back into a prompt.
Split resolveHighlightSegments out of buildHighlightParts so the mirror
overlay and the editor decorations share one priority resolution.
Not yet wired into ChatInput. Editor rendering is unverified: the package has
no DOM test environment, so only the state-level extension is covered.
* refactor(ui): move the composer onto the CodeMirror editor
Replace the transparent-textarea-over-mirror-div composer with ComposerEditor.
The mirror is gone, and with it the constraint that highlighting may only use
styles which do not change glyph advance width, and the mobile carve-out that
disabled highlighting entirely because wrapped text drifted from the caret.
Removals the editor makes unnecessary:
- measureCaretInTextarea, 46 lines of hand-built text mirroring for popup
placement, replaced by the editor reporting caret coordinates
- adjustTextareaHeight and its two layout effects, replaced by the editor
sizing itself; the dictation transcript height is now an explicit floor
- getInsertedTextFromChange, which diffed old against new text to recover what
a paste inserted; the editor reports the change directly
- the highlight mirror element, its scroll-sync and its parts memo
- a _commandMetadata property stashed on the textarea and never read
Caret placement no longer needs a requestAnimationFrame after a text edit:
text and selection travel in one transaction.
Runtime behavior is unverified -- the package has no DOM test environment.
Type-check, lint, the 295 chat tests and a production web build pass.
* refactor(ui): extract composer text and path helpers
Move the composer's text-splicing rules to composer/text.ts and its path
handling to composer/attachments/filePaths.ts, with tests. None of this logic
was covered before, despite handling VS Code drop payloads, percent-encoded
file URIs and Windows drive letters.
normalizeDroppedPath and toProjectRelativeMentionPath were useCallbacks that
closed over nothing but their argument and the search directory; they are now
plain functions taking the root explicitly.
* refactor(ui): make the composer's slash commands a table
Nine of the composer's local commands did the same thing -- render a visible
magic prompt plus synthetic instructions and send them as one message -- and
that shape was written out nine times as an else-if chain. Adding a command
meant copying twenty lines and remembering to change every string.
composer/submit/slashCommands.ts holds the commands as data and the shape as
one executor; ChatInput keeps only the five that manipulate session state or
open UI. The command names, prompts and failure toasts are now typed against
the magic-prompt and i18n key unions, so a mistyped key fails to compile
rather than failing at runtime.
Net: 257 lines of branching become 68.
* refactor(ui): move the composer's footer components out of ChatInput
RevertedMessageDock, ComposerAttachmentControls, PermissionAutoAcceptButton,
FocusModeButton and ComposerActionButtons each own a piece of the composer
chrome and were defined inline above the 4500-line component. They now live
under composer/ui with the imports they actually need.
Pure moves; getRevertedPreview travels with the dock, its only caller.
* refactor(ui): extract composer draft persistence
Move the draft lifecycle -- identity switching, debounced writes, external
deletion, and the flush-on-hide/freeze/pagehide edges -- into
composer/state/useComposerDraft.
It was seven interleaved effects and five refs sharing state through the
component body, which made the ordering constraints between them (skip the
next debounced write while restoring; record the empty signature before a
queued write can resurrect a deleted draft) invisible. They are now stated
where they apply.
* refactor(ui): extract drop payload inspection
hasDraggedFiles, collectDroppedFiles and collectDroppedFileUris were
useCallbacks with empty dependency arrays -- pure DataTransfer readers wearing
React clothing. They move to composer/attachments/dataTransfer.ts with tests
covering the host differences they exist for: browser File lists, VS Code's
proprietary tree types, OpenChamber's own internal-drag marker, and getData
throwing during dragover.
* refactor(ui): extract the mobile composer shell
Move the pill state machine into composer/state/useMobileComposerShell and
the visual-viewport pinning into composer/state/useMobileViewportPin.
Between them they held eight refs, four pieces of state and eleven effects
interleaved with the rest of the composer, which hid what they are: not one
state machine but a state machine plus a set of corrections for specific
platform behaviors -- mobile browsers dismissing the keyboard before a tap's
click lands, iOS refusing programmatic focus outside a gesture, WebKit leaving
the layout viewport panned after the keyboard hides, overlay chains handing
off through a frame where nothing is open.
Verbatim moves: every timeout, flushSync and guard keeps its value and its
reason, because none of them is verifiable outside a real device.
* refactor(ui): extract outgoing message assembly
A single send can carry queued messages, the composer text, inline review
comments, resolved @file attachments, a linked issue or PR, synthetic parts
from conflict resolution, and a skills instruction -- all flattened into
OpenCode's one-primary-plus-parts shape. The flattening rules were spread
through handleSubmit with no coverage, so the ordering they encode (oldest
queued message becomes primary; inline comments attach to the last authored
body, not a new part; PR instructions precede the diff) was trusted rather
than checked.
buildOutgoingMessage is a pure function over injected resolvers, with 25 tests
covering that ordering.
* refactor(ui): extract new-session draft targeting
Move project and worktree selection for the new-session draft into
composer/state/useDraftTarget.
The rule worth naming is that the draft can point at a directory that does not
exist yet -- a worktree still being created. It has to survive not appearing
in the branch list, or the selector snaps back to the project root mid-creation
and the session starts in the wrong place.
* refactor(ui): extract composer context chips and linked reference rows
The chips standing for attached-but-not-typed context (review comments, dev
server logs, preview annotations, terminal selections) were four near-identical
inline blocks; three of them collapse into one CountChip.
The linked issue and linked PR rows were 100 lines of duplicated markup
differing only in their number label, their branch line and which picker they
reopen. They are now one LinkedReferenceRow.
* refactor(ui): extract draft target selectors
Move the project and branch pickers into composer/ui/DraftTargetSelectors:
inline selects for desktop, trigger buttons and bottom sheets for mobile, all
rendering the same options from useDraftTarget.
The project label -- custom icon image, configured icon, or a folder fallback,
with the project name -- was a useCallback rendering JSX and used from four
places; it is now a ProjectLabel component.
* refactor(ui): extract the collapsed mobile pill composer
Move the pill into composer/ui/MobilePillComposer. Both places that ask
dictation to start now go through one toggleDictation callback rather than
dispatching the global event inline.
* refactor(ui): extract the composer footer
Move the footer row into composer/ui/ComposerFooter, which now owns the
desktop and mobile layouts and the components they place. ChatInput keeps only
the handlers it passes in.
* refactor(ui): collapse the composer's four autocomplete states into one
The composer tracked each picker with its own show flag and query string,
which encoded 'exactly one is open' as four booleans that had to be kept
mutually exclusive by hand. It is now one openAutocomplete kind plus one
query, which is what resolveAutocompleteTrigger already returns.
The four popup blocks -- identical apart from their component and caret width
-- become ComposerAutocompletePopups.
* refactor(ui): extract autocomplete positioning and message history
useAutocompletePosition owns caret-relative popup placement, which only
applies in focus mode.
useMessageHistory owns arrow-key recall. Its transitions are pure functions
with tests: entering history stashes the draft exactly once, so walking back
several messages and returning still restores what the user actually typed
rather than the last recalled message.
* docs: document the composer module
Record what each layer owns and the invariants that are not visible from the
code: that the prompt language is the single source of truth for syntax, which
ordering rules in the submit assembly and draft lifecycle are load-bearing,
that the mobile hooks are platform corrections rather than state machines, and
that rendering, focus, keyboard and WKWebView behavior are not covered by
tests and must be verified by hand.
* fix(ui): restore the composer caret colour and click-to-focus
Two regressions from the editor migration.
The caret rendered black in dark themes. CodeMirror's base theme hard-codes it
through '.cm-editor.cm-light .cm-content', one class more specific than the
plain '.cm-content' rule the composer theme used, so the base theme won.
Matching that specificity with '&.cm-editor' fixes both variants.
Clicking the composer's empty space no longer focused it. A textarea filled
its box, so the browser placed the caret for any click inside it; CodeMirror's
content element covers only the text. The content box now stretches to the
full editor height, and clicks landing outside it — in the composer's padding
— are forwarded to the nearest text position.
The theme moves to its own module with a test that installs it. EditorView.theme
compiles selectors at import and throws on scopes it was not given, including
'&light' and '&dark'; neither the build nor the type-check catches that, and
the failure takes the whole composer down at runtime.
* fix(ui): colour the composer caret where it is actually drawn
The previous fix styled caret-color, which drawSelection() overrides with
'transparent !important' at the highest precedence -- it hides the native
caret and draws its own .cm-cursor element, whose base style is a hard-coded
'border-left: 1.2px solid black'. So the caret stayed black on dark themes.
CodeMirror recolours that cursor only for editors that declare themselves
dark. OpenChamber themes are not merely light or dark, so the cursor takes the
surface foreground directly instead.
The theme spec is exported and asserted against: the caret rule must target
.cm-cursor, must not style caret-color, and must carry enough specificity to
beat CodeMirror's own &dark override.
* feat(ui): add emphasis, attention and path highlighting to the composer
The constructs the editor migration was for.
- **bold** and *italic* render as real weight and slant. They are additive
styles: a segment carries one class string, so choosing between weight and
colour would lose one of them -- bold inside a heading now keeps the heading
colour and gains weight.
- '!!! ' marks an attention line. Three marks, so a sentence ending in '!!'
is not swallowed.
- '~path' highlights a path without attaching it, unlike '@path'. Inert by
design: it feeds neither the autocomplete nor the send path.
False positives are excluded positionally rather than by character, since
these delimiters are ordinary prose: '2 * 3' and 'foo_bar' are not emphasis,
'~approximately' and '~1.2 seconds' are not paths.
Also fix the expanded composer, which kept the collapsed composer's eight-line
height cap: the editor scrolled inside an invisible window while the rest of
the surface sat empty.
* fix(ui): style the composer's selection instead of leaving CodeMirror's
Selecting text rendered it in CodeMirror's stock lavender, which buried the
token colours. drawSelection() paints its own layer and CodeMirror styles the
focused case through a six-class selector; the composer's rule was three deep
and lost.
The tint is translucent rather than the flat selection token: an opaque
selection hides the colours the composer exists to show, and selecting text
here is for moving it, not for stopping reading it.
Same failure shape as the caret, so the theme test now covers both.
* fix(ui): mute the composer placeholder
The placeholder rendered at full text brightness. Its colour referenced
--surface-mutedForeground, but the theme emits --surface-muted-foreground:
an unknown custom property makes the declaration invalid rather than falling
back, and since color inherits, the placeholder simply took the editor's text
colour while the source looked correct.
The theme test now rejects camelCased tokens outright, since this failure is
invisible in every check that does not render.
* fix(ui): create the composer editor before the expand gesture ends
The mobile pill expands with flushSync and focuses the editor on the very next
line, still inside the tap's call stack, because that is the only way a mobile
browser raises the keyboard. The EditorView was created in a passive effect,
which flushSync makes no promise about — so at the moment focus() was called
there was no view to focus.
With a textarea the element existed as soon as flushSync returned, which is
why this worked before the migration.
Creating the view in a layout effect restores that ordering.
* perf(ui): keep the composer editor alive across the mobile pill swap
The pill and the full composer are different subtrees, so expanding or
collapsing unmounted and rebuilt the editor. With a textarea that was one DOM
node. A CodeMirror view is extensions, state, document, decorations and a
first measure — all inside the tap's flushSync, before the browser is allowed
to paint the swap. The shape change therefore landed late enough to look
driven by the keyboard rather than by the tap.
The view now lives in a store owned by ChatInput and is detached and
re-attached instead of destroyed and rebuilt. Its extensions read callbacks
through a ref held by the store, so a kept view always calls into the mounted
instance; compartments move to module scope, since per-instance ones would be
unknown to a reused view's configuration.
Also restore the caret hold: WKWebView draws the caret as a native layer that
ignores CSS transforms and visibly flies across the screen during the keyboard
slide. The rule hiding it targeted textarea and input, which the composer is
no longer — and its caret is now a drawn .cm-cursor element rather than the
native one.
* debug(ui): on-screen timeline for the mobile composer swap
TEMPORARY, Capacitor-only. Two plausible fixes for the swap lagging behind
the keyboard changed nothing, so the theory behind them was wrong. This
overlay draws the event timeline straight onto the screen -- the tap, the
committed swap, the first paints after it, the keyboard choreography, and
whether the editor was created or re-attached -- so one screenshot replaces
guessing. It doubles as an asset-freshness check: no overlay means the app
runs a bundle from before this commit.
* fix(ui): put the composer swap on glass before the keyboard moves
The overlay timelines settled it. The swap itself was never slow: commit in
12ms, editor re-attach in 3ms, focus immediate. What lagged was presentation:
WKWebView stops presenting web frames the moment focus starts the keyboard
transition and holds the last presented frame until it ends. Focusing in the
same task as the swap meant the last presented frame still showed the pill —
paint-1 fired at 29ms, the next frame at 190ms, exactly when the keyboard
was already moving.
On expand, Capacitor now waits two frames before focusing, so the swapped
composer is presented first and the keyboard rises under it. The Capacitor
WebView raises the keyboard for a focus() outside the gesture task; mobile
browsers do not, so they keep the synchronous path.
The collapse direction had a genuine race, caught on one screenshot: the
oc:keyboard-intent collapse arrives a few milliseconds after blur on a
setTimeout(0), and React's scheduling of setFocused(false) can lose to it —
busyRef stays stale, the intent handler skips the instant collapse, and the
pill appears via the 250ms fallback, 370ms after the keyboard has gone.
The Capacitor blur branch now commits the state with flushSync.
The diagnostic overlay stays in until this is confirmed on device.
* fix(ui): raise the keyboard from the swap's first frame
Two frames of delay before focusing made expand visibly sequential: swap,
then keyboard. Focusing inside the first frame after the commit puts the
swap's frame into the rendering pipeline before the keyboard transaction
starts, so the keyboard rises from the tap and the composer appears during
the rise rather than after it.
* fix(ui): restructure the draft screen in the same frame as the pill swap
The draft screen centers its title over the space the composer leaves, and
its starter chips leave when the keyboard is up. The chips were keyed on
oc-keyboard-open, which lands with the keyboardWillShow bridge event ~100ms
after the tap — so expanding the composer restructured the page twice: once
at the swap (composer grows, title re-centers) and again mid-keyboard-rise
(chips vanish, title re-centers again). Chat has no centered content, which
is why it was already smooth and the draft screen was not.
A root class now announces the expanded composer from a layout effect, in the
same frame as the swap, and the chips key on it: one restructure, fused with
the pill morph, before the keyboard moves. The keyboard classes remain as
fallbacks for keyboard-up states that do not go through the pill.
* debug(ui): remove the mobile swap timeline overlay
The diagnostic did its job: it identified WKWebView's presentation pause
during keyboard transitions, the React-scheduling race in the collapse path,
and the draft screen's double restructure — all fixed and confirmed on
device.
* feat(ui): grow the mobile composer with content, drop the fullscreen handle
The swipe-up handle promised a fullscreen composer but the normal eight-line
cap already reached within a line of the same height, so the gesture bought
almost nothing and cost a 28px bar above the editor.
The composer now just grows with what is typed: a generous line cap plus a
CSS ceiling of the space the keyboard actually leaves, whichever is smaller
(the editor cap accepts both and takes min()). The handle, its swipe
gestures and the shell's touch plumbing are gone.
* fix(ui): measure the mobile composer ceiling instead of estimating it
The 220px chrome constant guessed at what surrounds the editor. The old
fullscreen handle guessed at nothing — it let flex distribute real space (and
on Capacitor even that silently failed: its h-full resolved against a
shrink-wrap parent, which is why the gesture bought almost nothing).
The ceiling is now measured the way the handle meant to: the screen container
is marked data-composer-bound, and the editor may grow until the composer
fills it — chrome around the editor read live from the DOM, so attachment
chips, the model row and keyboard resizes all shift the cap by themselves.
* fix(ui): keep a 4px gap between the grown composer and the header
On the chat screen the fully grown composer's border landed exactly on the
header's bottom edge. The gap is a visual design choice, not another chrome
estimate: the ceiling itself stays measured.
* fix(ui): show iOS selection handles without giving up typing speed
iOS pins its selection drag handles to the visible native selection and
colours them from the caret, while drawSelection() hides both. Removing
drawSelection(), or leaving the native caret visible while typing, both
make iOS answer every keystroke with severe input lag. Touch devices now
keep drawSelection() and layer a theme over it that re-shows the native
selection, plus an .oc-native-range marker that enables the native caret
only while a range is selected — when there is no caret to lag on.
* feat(ui): make file mentions editable instead of atomic-delete
Deleting a character inside an @file mention edited nothing and erased the
whole token. Mentions now edit like /skill tokens: a deletion changes the
text and the caret position reopens the file picker on its own. findMentionAt
and MentionToken.rawEnd existed only for the atomic delete and are removed.
* fix(ui): composer selection visibility and external-insert caret
Selection was nearly invisible for two reasons: the tint was mixed down from
--interactive-selection, which themes define with its own alpha (often under
10%); and the painted selection layer sits behind the content, so tokens with
their own background (inline code, fences) covered it entirely. The native
selection now shows on every device, not only touch — it paints over token
backgrounds — and its tint comes from --primary at 25%, a full-strength
colour in every theme. drawSelection() and the range-scoped native caret stay
exactly as before, so typing keeps the lag-free path.
External rewrites (add-to-chat, draft restore, history, dictation) also left
the caret at its old position, so the next insertion landed inside the
previous one. They now put the caret at the end, as the old textarea did, and
pin the scroller to the bottom once the layout settles — a transaction-time
scrollIntoView fires before the max-height cap exists and scrolls nothing.
* fix(ui): render ***triple emphasis*** as bold italic
The emphasis tokenizer capped delimiter runs at two characters, so ***x***
parsed as a stray asterisk plus an italic span. Runs of three now emit both a
strong and an emphasis range over the same content; the two are additive
styles, so they compose into bold italic.
* fix: insert mentions through editor dispatch
Places the caret immediately after an inserted mention
Avoids rewriting the whole message and jumping the scroll to the bottom
Falls back to appending inline text when no editor is available
Improve OpenChamber responsiveness under large session workloads while fixing
cache, synchronization, and persistence correctness across runtimes, projects,
directories, and worktrees.
- prioritize selected and visible sessions during bootstrap and defer
non-critical enrichment work
- reduce redundant message loading, event processing, store publication, and
hidden sidebar work
- prevent stale session and message requests from overwriting newer
authoritative state
- preserve existing data when authoritative fetches fail instead of treating
failures as successful empty responses
- scope session materialization, messages, drafts, queues, todos, pins,
permissions, folders, tabs, Git state, and pull request data by runtime and
directory identity
- harden runtime switching, reconnect, cleanup, mutation reconciliation, and
persisted-state ordering
- preserve live subagent Task linkage when metadata arrives after an older
message request or while streaming parts are suspended
- coalesce overlapping tail refreshes without losing newer refresh demand
- improve cold-session loading by moving deferrable work out of the critical
bootstrap path
- isolate URL authentication, mobile credentials, native secrets, and other
runtime-owned state across endpoint changes
- bound long-lived caches and remove avoidable allocations from event and
rendering hot paths
- limit virtualization to archive collections where it improves rendering
without disrupting active sidebar layout
- stabilize session folders, pin ordering, expanded state, and persisted
sidebar behavior
- open skill files through the same secure editor and outside-workspace grant
flow used by file navigation, including worktree sessions
- expand regression coverage for stale completions, runtime collisions,
reconnect behavior, persistence races, authoritative empty results, and
subagent refresh ordering
- document the updated synchronization, cache ownership, performance, and
runtime-isolation invariants
* feat(chat): prompt navigator list preview with prompt filtering
The hover preview is now an interactive scrolling mini-list of prompts:
rows render as bordered two-line cards, the highlighted row stays inside
a center dead zone and the list glides only near the window edges, wheel
steps the highlight, and the panel stays open when the pointer moves into
it so a click can be corrected inside the list.
Rail entries are filtered to real prompts: previews are built from
normalized user display parts (synthetic context stripped), fully
synthetic user messages are excluded, and shell-mode messages show their
extracted command via the shared shell bridge helpers.
* fix(chat): render shell command status transitions
The injected /shell text part carries live state in shellAction, which
the render-relevant part comparator ignored — a running→completed update
reached the store but never re-rendered the message row until the next
send. Compare shellAction command/output/status for text parts.
* fix(sync): stream shell bridge part updates while running
Streaming suspension keeps part updates out of the static message records
while an assistant message streams, relying on the live streaming-tail
path to render it. Shell-mode bridge messages are hidden from the
timeline and rendered inside the user row, so they have no live path —
suspension froze their output chunks and left the card without a Show
output action until the run finished. Exempt shell bridges (single bash
tool part parented to a synthetic shell-marker user message) from
suspension; their updates arrive at command-output pace, not delta pace.
* feat(chat): syntax-highlight shell command card
Render the shell-mode command and its output through the shared
WorkerHighlightedCode (Shiki) with bash grammar, matching the bash tool
part presentation, instead of plain pre blocks.
* feat(chat): add desktop prompt navigator rail
Add a ChatGPT-style right-center prompt marker rail for web/desktop chat
with hover/keyboard preview panel, load-more for partial history (panel only),
Chat setting, and mod+alt+p shortcut. Disabled in VS Code across rail,
shortcut, settings, help, and search surfaces.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* fix(ui): read promptNavigatorEnabled from getState in shortcut handler
Match the file convention used by other shortcut handlers so the toggle
does not rely on a hook-level selector closure.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* fix(ui): drop use-no-memo and default prompt navigator off
Remove the project-unprecedented React Compiler opt-out, and ship the
prompt navigator as opt-in to match other recent chat UI toggles.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Adds a load older button when earlier history is available
Preserves scroll position while older messages are loaded
Shows date-grouped messages with clearer per-message timestamps
Adds a server-side "small model" capability: direct, cheap LLM calls that
reuse the user's existing OpenCode provider logins — the mechanism OpenCode
uses internally for titles and summaries but does not expose through the
SDK or plugins. Zero new dependencies; plain fetch with per-provider wire
formats, credentials never leave the server.
Core (packages/web/server/lib/small-model):
- Resolution mirrors OpenCode's session scoping: explicit settings override
→ small_model from the OpenCode config → family scan within the session's
provider → the session's own model. The global provider scan only serves
callers without a session context, and background callers forbid it
entirely (restrictToPreferredProvider), so conversation content never
reaches a provider the user didn't pick — explicit choices excepted.
- Per-provider auth replicating OpenCode's plugin loaders: GitHub Copilot
(device token as bearer, no exchange), ChatGPT plan via the codex
Responses API (single-flight OAuth refresh written back to auth.json),
Anthropic messages, Google generateContent, generic OpenAI-compatible.
- OpenCode's free models (opencode/big-pickle, *-free) are never called
directly; unauthenticated providers are skipped by design.
- Prompt clamping to the model's catalog context limit; thinking disabled
where a wire switch exists (Z.AI/GLM, MiniMax-M3, Gemini Flash); robust
content parsing with a clear error when a thinking model spends its whol
budget on reasoning.
- Settings → Sessions gains a Small Model group: use-default checkbox plus
an override picker limited to authenticated providers, persisted with
web/desktop/VS Code sanitization parity.
Consumers:
- Session assist: a server-side watcher on the global SSE hub generates a
short recap and one suggested follow-up after a session idles quietly fo
a minute, stored on session metadata (openchamber.assist). Freshness is
keyed to the last assistant message id, so new activity invalidates the
payload everywhere with no extra writes. The chat shows the recap under
the last message after five quiet minutes and the suggestion as a
dismissible chip above the composer (tap fills the input, never sends).
Gated by a new Chat setting (default on) that is a hard generation
switch. Language is anchored to the conversation itself, with a
script-mismatch guard against model/backend language hallucination.
- TTS: a third input mode, summarized — long replies are condensed to
spoken prose before playback on any TTS engine.
- Git: commit-message and PR generation moved off the active chat session
onto the small model fed with real diffs and the commit list (bodies
included), with a session-transport fallback for free-model-only setups.
- Notes: Add to notes distills long selections into 1-3 dense sentences
preserving exact identifiers, with verbatim fallback on failure.
Fixes along the way:
- The global event watcher now starts unconditionally; it was gated behind
the desktop-notify env, leaving the server-side event hub dead in
packaged apps.
- OpenCode re-emits message.updated for old user messages after idle; the
watcher no longer mistakes those for new activity.
- Session metadata merges from a fresh read right before the PATCH, so
writes made during the generation window (suggestion dismissals, review
links) are preserved; the assist runtime stops during graceful shutdown.
Mobile browsers don't shrink the layout for the keyboard, so the
fullscreen composer is now pinned to the visual viewport (fixed at its
offset and height, tracked as the browser pans) instead of overflowing
underneath it; the draft screen's normal composer gets the same pinning
anchored to the visible bottom via a rAF tracker, since Safari's own
focused-field reveal proved unreliable there after leaving fullscreen.
The draft and empty-session roots drop their transform-gpu (a transform
would make them the containing block for the pinned form), the app
header hides while the browser fullscreen composer is up (the form can't
out-stack it from inside the composer wrapper's stacking context), and
leaving fullscreen nudges the still-focused field back into view.
Draft starter chips now hide while the keyboard is open in browsers too,
via an oc-browser-keyboard-open root class driven by composer focus.
The button's visibility mixed the sync meta with the prefetch-cache hint;
a stale prefetch entry (cursor recorded at the initial page) could keep
the affordance alive after the user had already loaded to the top. Sync
now exposes an explicit isComplete (positive confirmation from a fetch,
distinct from !hasMore on unpopulated meta) and it overrides the prefetch
hint, which stays in effect only before the meta knows anything.
Drop the animated pill/composer morph in favor of instant swaps that are
synchronized with the keyboard choreography: a new oc:keyboard-intent
event collapses the composer (flushSync) before the hide compensation is
measured, so keyboard travel and composer height change land as a single
chat motion on both iOS and Android (Android also gains keyboard signals
and deterministic re-pins around its native resize). The WKWebView caret
is hidden during the transition so it no longer flies to its new position.
Draft screen: starter chips hide instantly while the keyboard is up and
the centered title rides the keyboard shift compensation instead of
double-jumping; the composer drag handle also works in dictation mode;
the highlight mirror is disabled on mobile so the caret matches the text.
Fixes: worktree discovery and the GitHub auth probe now wait for the
runtime connection (no more empty branch pickers / stale auth on cold
start), worktree discovery merges per project instead of clobbering the
persisted map, the cross-project session list resets on instance switch
(with an in-flight load guard) so no stale sessions linger, and mobile
overlay content contains its overscroll instead of bouncing the page.
Mobile composer redesign: when the keyboard is closed the input collapses
into a narrow pill (sessions, attach, placeholder, mic) with a round
new-session button that fades away on the draft screen. Model and agent
selectors move into a row above the textarea; the draft project/branch
pickers and the attachment menu become searchable bottom sheets reusing
MobileOverlayPanel; a drag handle (also available while dictating) swipes
the composer into and out of a fullscreen mode.
Keyboard-lifecycle hardening: composer controls (agent cycle, dictation
and its overlay controls) no longer steal focus and dismiss the keyboard;
overlays reopen the keyboard on close via a debounced restore chain that
survives menu-to-picker handoffs and skips the native file picker; open
overlays and dictation keep the composer expanded. Dictation starts
directly from the pill and its overlay content fades in after the shape
settles. The keyboard slide compensates the pill-to-full height change in
one motion, and the mobile highlight mirror is disabled so the caret
always matches the text layout.
- Replace virtua with @tanstack/react-virtual for chat history on all
surfaces: bottom anchoring (anchorTo: end), key-stable prepend
preservation, and native iOS touch/momentum deferral live in the core
- Patch virtual-core to clamp the render range to real scroll bounds
during transient adjustments
- Rows render in normal flow inside a translated wrapper so sticky user
headers keep working; measurement snapshots cached per session
- Pre-write container height in scrollToFn so the browser cannot clamp
anchor corrections to the stale height; hold the prepend anchor for up
to 180 frames on mobile while fresh rows settle (cancelled by user
input; desktop relies on core anchoring alone)
- Adaptive row-size estimate from per-session measured averages; disable
reveal fade-in for virtualized history rows
- Mobile loads older history only through an explicit localized top
button: no scroll-position trigger and no post-mount background
prepend, so every insert happens from a resting state; a quiet-window
hold defers any stray prepend commit while a touch gesture is active
- Desktop/VS Code keep the seamless scroll-up trigger and progressive
background prepend
Two scroll owners were writing the chat container's scrollTop concurrently
during pinned content growth and on send, fighting frame-to-frame and
producing the reported flicker/jiggle (after a pause, from the queue, on user
interruptions) plus a visible double scroll on a normal user send.
Enforce a single-writer invariant in useChatAutoFollow:
- The easing follow loop and the instant settle burst now mutually exclude:
starting one stops the other, so they can never write scrollTop in the same
frame. The isFollowingProgrammatically flag (which suppresses the overlay
scrollbar) is owned by whichever loop is active and cleared only when both
are idle, including the settle burst's natural 280ms end.
Stop the redundant re-pin storm in useChatTimelineController:
- While pinned, route goToBottom('instant') only for a prepend (history loaded
above), not on every bottom append / streaming part. Normal growth is owned
by the follow loop (kicked by the content ResizeObserver and chunk handlers).
Remove the double movement on send:
- Add scrollToBottomOnSend: when already following, just (re)kick the follow
loop for a single smooth movement instead of also firing an instant
goToBottom that raced the ResizeObserver-driven loop. When released (scrolled
up), keep the instant jump to the just-sent message.
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.
Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.
Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.
Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.
Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.
Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
On refresh the auto-open draft opens in an effect (after first paint), so
ChatContainer briefly rendered ChatEmptyState (logo + 'start a new chat')
before the welcome screen. When a draft is about to auto-open and there's
no init error, render a neutral background instead — the empty state stays
for the cases where it's actually meaningful (no auto-open, or init error).
Mini-chat uses the same centered-welcome compact layout, so the preset chips fit fine under the welcome message there as well. Drop the mini-chat exclusion.
On narrow surfaces (mobile, vscode) the composer sits at the bottom and
only the welcome message is centered, so the preset chips had nowhere
sensible to live and were desktop-only. Render them under the centered
welcome message there instead.
Extract the shared preset list (draftPresets.ts) and chip row
(DraftPresetChips) so both layouts reuse them. Since the command-aware
submit lives in ChatInput, ChatContainer triggers it through a new
input-store channel (requestPresetSubmit / consumePendingPresetSubmit)
that ChatInput consumes. Mini-chat stays excluded — too small for the row.
Fix chat history pagination and scroll preservation
Align session history loading with the expected scroll-up pagination UX while
keeping OpenChamber-specific initial message limits for constrained runtimes.
- Separate initial load sizes from older-history pagination size
- Load older messages automatically when scrolling near the top
- Continue fetching history until a visible older turn is available
- Preserve the current viewport synchronously during prepends
- Prevent history loading from fighting pinned-to-bottom follow behavior
- Remove delayed scroll-to-bottom correction that caused jumpbacks
- Fix the virtualizer fallback path that could render a large blank spacer
- Track oldest loaded message per pagination iteration to avoid redundant fetches
Improve chat session switching and history pagination, with most of the aggressive limits scoped to the VS Code webview where the freezes were observed.
Session history loading and pagination:
- Reduce the VS Code message page size to 30 records so switching sessions does not immediately hydrate large histories into the webview.
- Keep manual Load older messages in VS Code fixed at 30 records per request instead of growing the request size over time.
- Add a bounded VS Code initial-tail expansion path from 30 to 50, 80, and 120 records only when the initial page has no user-message turn boundary, preventing large final turns from rendering as an empty chat.
- Lower the normal web message page size from 200 to 150 for a mild shared optimization without adopting the aggressive VS Code limits.
- Make session pagination metadata reactive per session so ChatContainer receives cursor updates from materialization and reconnect paths without requiring a switch away and back.
- Write pagination metadata before publishing newly materialized messages so the first render sees the correct has-more state.
- Store cursor information from direct materialization and reconnect message fetches in the shared session prefetch metadata cache.
VS Code cache and memory pressure reductions:
- Use a shared per-directory session recency map so cache eviction is based on app-level recency instead of whichever useSync instance happened to run.
- Limit VS Code warm session cache retention to 4 sessions and evict heavy inactive message caches after switching away from a large session.
- Disable sidebar session prefetch in VS Code because warming extra sessions was increasing webview memory and GC pressure during navigation.
- Remove dropdown background message prefetch so opening the switcher does not start additional session materialization work.
- Drop cached session-message-record snapshots when evicting session data so stale derived records do not remain after the raw session cache is cleared.
- Add bounded LRU caching for session message record snapshots, with much smaller VS Code limits and a VS Code cap that avoids caching snapshots above 30 messages.
- Bound the turn-window model cache in VS Code and avoid caching turn models for sessions above the VS Code message-page size.
Chat render-path reductions:
- Reuse ChatContainer's already-materialized message records in plan detection instead of adding a second active-session message subscription.
- Add a no-op guard when marking session plan availability so repeated detections do not create new Map references and fan out renders.
- Add no-op guards for session switcher and dropdown open state updates to avoid unnecessary store updates and renders.
- Convert several session-specific hooks to useSyncExternalStore with empty-session no-subscribe behavior so empty IDs do not subscribe to broad store updates.
- Remount the chat viewport when the current session changes, isolating per-session viewport and list state.
- Change the virtualized message-list fallback to render only a tail window when the virtualizer has not produced rows yet, instead of rendering an entire large history.
VS Code layout and header improvements:
- Remove the broad useSessions subscription from the VS Code layout header path and subscribe only to the active session title and initial-session existence.
- Unmount the compact VS Code session sidebar when the user is in chat view instead of keeping the hidden session list mounted and subscribed.
- Compute the latest assistant model and latest context-token usage in a single reverse scan of current-session messages instead of scanning the same list twice.
- Remove switcher git-status warmup work so the switcher reads already-loaded branch labels without starting extra background git status requests.
Markdown and file-reference safeguards:
- Skip expensive syntax highlighting for very large code blocks, with a 200-line cap in VS Code and a softer 1200-line cap in web.
- Add an LRU cap to file-reference stat lookups so the cache cannot grow without bound across many rendered messages.
- Limit the number of file references annotated per render to 40 in VS Code and 200 in web to prevent large assistant outputs from spawning too many stat checks.
- Clear file-link annotations when file-reference mode is disabled so stale attributes and handlers do not remain on previously annotated nodes.
Assistant-message action and preview reductions:
- Skip preview URL scanning on VS Code, mobile, and mini-chat surfaces so assistant text and tool output are not scanned where the preview action is unavailable.
- Skip Save-as-Plan project lookup on VS Code, mini-chat, and mobile surfaces.
- Hide Save-as-Plan and Start MultiRun assistant-message actions on VS Code, mini-chat, and mobile surfaces.
- Resolve the current session directory on demand for assistant actions instead of subscribing each assistant message to the full session list.
Tool and task rendering optimizations:
- Prefer finalized task metadata summaries without fetching child-session messages when the summary is already present.
- Avoid polling or final-fetching task child sessions once a final metadata summary is available.
- Use VS Code-specific task child fetch limits of 30 records for initial, active, and idle fetches.
- Parse diff stats by scanning patch text line-by-line instead of splitting large patches into arrays.
- Count write-tool lines by scanning content instead of allocating a split array for large files.
- Avoid trimming large patch strings just to test whether they contain content.
- Memoize diff and write statistics so unchanged tool parts do not recalculate them on every render.
VS Code bridge improvements:
- Return JSON and text proxy responses through the VS Code bridge as bodyText instead of base64 so the webview avoids synchronous base64 decoding for common API responses.
- Keep binary responses on the base64 path while making bodyBase64 optional in the bridge contract.
- Strip content-length, content-encoding, and transfer-encoding headers from proxied responses because the bridge reconstructs the Response body.
Validation:
- bun run type-check
- bun run lint
- bun run vscode:build
Adds read-only embedded chat mode without hiding permission prompts
Opens subagent sessions in the context panel instead of replacing the main chat
Fixes context panel message loading for embedded sessions
Replace @remixicon/react with a shared Icon component that renders
via <use href> references to a single hidden SVG sprite. This reduces
DOM node count by replacing inline SVGs with lightweight references.
- Create Icon component with sprite injection (packages/ui/src/components/icon/)
- Migrate all 164 files from @remixicon/react to Icon component
- Auto-generate sprite data from remixicon bundle (scripts/generate-icon-sprite.mjs)
- Add bun run icons:generate to package.json
- Move @remixicon/react to devDependencies
- Add icon usage instructions to theme-system skill
The previous system layered three hooks (useScrollEngine, useChatScrollManager,
useChatTimelineController) with overlapping responsibilities, four parallel
ResizeObservers/MutationObservers, and six entry points to "scroll to bottom"
(force-flag combinations, persistent follow loops, materialization recovery).
This produced bugs where users could not break free of auto-follow during
streaming: scrollbar drag, keyboard scrolling and find-in-page were not
detected as user intent, and observers kept restarting the follow loop on
every DOM mutation.
The new architecture replaces the two low-level hooks with a single
useChatAutoFollow that owns scroll behaviour end to end:
- One state: 'following' or 'released'. No follow modes, no pin flags,
no marker pixels.
- One scroll writer: a lerp loop that runs only while the session is
streaming and state is 'following'. Idle sessions never write scrollTop
programmatically.
- One user-intent detector: wheel up, touch drag down, keyboard
(PageUp / Home / ArrowUp), pointerdown on the OverlayScrollbar thumb,
and explicit releaseAutoFollow() calls all flip the state to 'released'.
- A 1.2s grace period after explicit release: re-pin will not auto-engage
inside this window, so a small wheel up cannot snap the user back even
while they remain near the bottom spacer.
- Re-pin and the scroll-to-bottom button share the same threshold: the
height of the empty bottom spacer (10vh on desktop, 40px on mobile).
Released users see the button only after they have scrolled past the
spacer that already exists at the end of the chat.
- Save/restore of scroll position uses ratio mapping, debounced at 150ms
on user-driven scroll events; programmatic writes are masked via a short
window so they never persist as user positions.
- Container reattachment is detected via a useLayoutEffect probe over
scrollRef.current. Listeners and observers re-bind when ChatViewport
mounts after hydration or after the first message promotes a draft
session into a real chat.
- A pending-restore queue replays restoreSnapshot once the scroll
container appears, fixing the case where a hydrating session landed at
the top instead of the bottom.
Removed: useScrollEngine.ts, useChatScrollManager.ts, the persistent
follow loop with its own ResizeObserver+MutationObserver pair, the
materialization-recovery .finally resume that yanked idle users to the
bottom on transient sync gaps, and the openchamber:session-reselected
event (re-select still works through the existing onSessionSelected
callback). The openchamber:chat-force-scroll-bottom event remains for
synthetic-message paths like git-message generation.
Net change: ~1300 lines removed, two hooks replaced with one, one
observer pair instead of four.
Add dedicated Electron Mini Chat windows for focused chat sessions without the full desktop shell. Mini Chat can open existing sessions or draft sessions, supports pinning above other windows, transfers sessions or drafts back to the main window, and deduplicates existing-session windows.
Expose Mini Chat entry points from the main header, session sidebar, command palette, and `mod+alt+n`. Add a dedicated Vite entry and React runtime so the compact surface can stay isolated from full-app chrome while still sharing chat, sync, theme, locale, model, agent, and worktree behavior.
Keep Mini Chat behavior scoped to the compact surface:
- limit assistant/user message actions to the appropriate Mini Chat set
- hide workspace changed-files UI in Mini Chat
- keep draft worktree selection and streaming directory state in sync
- mark sessions viewed while they are open in Mini Chat
- support Mini Chat-specific keyboard shortcuts for input focus, model selection, thinking variant cycling, favorite model cycling, and opening new Mini Chat drafts
Harden Electron integration by gating Mini Chat controls on desktop IPC availability, restricting pin/unpin IPC to Mini Chat windows, and only closing Mini Chat after the main window handoff succeeds.
Canonicalize session message/part materialization across load, prefetch, reconnect, and recovery paths so OpenChamber restores session snapshots through one consistent merge flow.
Preserve live assistant streaming text when stale or delayed snapshots arrive, while still replacing optimistic user parts with confirmed server snapshots to avoid duplicated user messages.
Narrow recovery triggers to explicit incomplete snapshot signals instead of broad session-event fallbacks, reducing unnecessary session refetches during active streaming.
Keep turn windowing aligned with parented assistant replies and add regression coverage for materialization gaps, stale snapshot protection, optimistic user replacement, reconnect recovery, and turn grouping.
* fix: restore scroll position when switching chat sessions
When switching between chat sessions, scroll position now restores to
where the user left off instead of always jumping to the bottom.
- Save pixel-level scrollPosition (scrollTop/scrollHeight/clientHeight)
in viewport store on every scroll event
- Add restoreSavedScrollPosition to timeline controller for ratio-based
restoration (handles content size changes between visits)
- Suppress intermediate scroll events during session transition with an
explicit flag, cleared deterministically after restore completes
- Cancel in-flight animations/follow-loops on session switch
- Preserve scrollPosition when session-ui-store rebuilds SessionMemoryState
* fix: keep streaming sessions pinned on restore
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>