Keeps idle readers from snapping back to the end while rows re-wrap
Releases the pin instead of scrolling if a resize moves an idle view off the end
Re-asserts the live edge after resize settle for active streaming sessions
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.
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.
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.
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.
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.
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
The Ctrl/Cmd+digit shortcut for switching context surfaces fired even
while the user was typing in an editable target (input, textarea,
select, or contenteditable element), hijacking the keystroke. Guard the
switchSurfaceDigit branch in useKeyboardShortcuts with the existing
isEditableEventTarget helper, matching how other shortcuts in the same
hook already bail out of editable targets.
Closes#2503
Merge main and reshape the follow opt-out to the gestures the timeline was
missing: a middle-button press starts the platform autoscroll pan (the only
scroll gesture on wheel-less mice and tablets with a pointer), Shift+Space
scrolls up from the keyboard, and an upward wheel over a nested scroller that
still has room above stays with that scroller instead of releasing the chat.
The grace re-pin timer and scroll-direction tracking are dropped: returning to
within the end band already re-arms follow, and the mode machine is built
without timers on purpose. Pause/Break never move the viewport and are not
gestures.
Closes#1640
The list's animated maintainScrollAtEnd is single-flight and re-pins only
within a tenth of the viewport, so in a narrow viewport (VS Code sidebar) each
revealed block — several screens tall there — left the reader a second behind
and multiple screens above the live edge. The timeline hook now follows growth
itself: glide when within a viewport of the end, otherwise jump to one screen
above the end and glide the rest. Measured at 420x640: time spent >40px behind
dropped from 45% to 11%, max distance from 1682px to 382px.
Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
With "Follow new content while streaming" disabled, sending from mid-history
no longer anchors the new message at the top of the viewport; the reader stays
where they were and the scroll-to-bottom pill leads to the new turn. Sending
from the live edge still parks the message as before. The setting's info text
now describes this in every locale.
Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
Chromium scrolls overflow:hidden ancestors when a textarea caret moves out
of view (PageUp/PageDown in the prompt box, long prompts), shifting the whole
app up and hiding the title bar with no way to scroll back. Snap html/body/#root
back to zero on any root scroll event in the web, desktop, VS Code and mini-chat
apps.
Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
Saved Project knowledge plans opened as an empty editor whenever the
viewer could not resolve the owning project from the current directory:
managed chats (openchamber:chats is not a registered project), worktrees
outside the repo path, and plan tabs restored after a reload. Titles
still rendered because the list reads the manifest through the correct
owner.
- Thread the owner explicitly (savedProjectPlan = { projectRef, planId })
from the panel, mobile surfaces, and persisted context tabs; PlanView
no longer guesses the project.
- An unrecognized directory resolves to no owner instead of borrowing
the active project's knowledge.
- Serialize plan writes per document (planSaveQueue) so close/switch
within the autosave debounce no longer drops the last edits, saves
cannot land out of order, and a recovered save clears the error banner.
- Send saved-plan contents inline in Improve/Implement prompts (they
have no file path); disable those actions for managed-chat plans,
which have no project directory to create a session in.
- Drop persisted plan tabs that carry an id without an owner rather than
reopening them against a guessed project.
With streaming auto-follow off nothing moves the viewport, so a growing
reply slides below the composer without a single scroll event and the
at-end transition that shows the pill never fires. Content growth now
doubles as the signal: once the measured last row extends past the visible
area by the follow re-arm threshold, the end state clears and the pill is
scheduled.
Opening a session (or any relayout that shrinks off-screen size estimates)
could leave the viewport in a phantom tail below the measured content, with
every row out of reach above; a totalSize-change check now detects the
fully blank viewport and returns to the real end, and settling a width
resize re-asserts the end for a reader who was on it. Prompt-rail and
message jumps land on estimated offsets that shift as the target mounts and
measures; a short settle loop now re-aligns the target until layout rests,
backing off on the first user gesture.
A trailing configure button on the rail — outside the sortable list and the
digit shortcuts — opens a dialog that toggles each surface. The choice is
stored as the hidden set so newly added surfaces appear for everyone, and
the rail and the mod+alt+digit switcher share the same visibility filter, so
badges and shortcuts always agree. Hidden surfaces keep their data and stay
reachable from the command palette.
mod+alt+arrows step through this window's session-open history (or between
neighbouring tabs when session tabs are on), mod+k r renames the current
session inline, and mod+k a toggles permission auto-accept. Pending
permission cards respond to alt+enter / alt+shift+enter / alt+backspace with
the keys printed on the buttons. The commit message box commits on
mod+enter, alt+arrows step the diff review between changed files, and the
command palette gains search-only commands for rare actions so the initial
list stays short.
Single chords stay for everyday actions; open/go actions move to two-step
mod+k sequences; held mod+digit switches header session tabs and held
mod+alt+digit switches context panel surfaces. Rare actions leave the
shortcut schema for the command palette, every remaining action ships with
a default binding, and stored overrides from the old layout reset once.
Key matching now follows the physical key on non-Latin layouts and for
Option-modified digits on macOS, including in the recording dialog.
The schema/config/bindings/registry/dispatcher module, useKeybind hooks,
recording dialog, reworked shortcuts settings page, help dialog, and the
localized action labels — re-based onto current main rather than merged
(the branch predates 440+ commits including the session-tabs shortcuts).
Review fixes applied on top of the original:
- close_session_tab (alt+w) joins the schema with labels in every locale;
it shipped on main after the PR's base and would otherwise silently die.
- switch_context_surface's special-case in conflict resolution is now a
declared prefixStyle config property instead of a magic id string.
- Duplicate handler registration warns in dev builds.
- The risky-browser-shortcut warning inspects every chord and covers
mod+q/d/h/j/o/u plus mod+shift+w/q.
- The dispatcher remembers which target armed a two-chord prefix so the
window-level completion handler can distinguish a deliberate sequence
from typing in an editable field (guard lands with the dispatch hook).
- Schema tests: unique normalized default bindings enforced, and the
flat-file-era override format proven to keep resolving.
A corrupt nested repository can be discovered (its .git entry exists)
but fail its status probe. The stale-selection recovery cleared the
pick and forced a re-scan, and auto-select immediately re-picked the
same path, looping discovery walk + probe for as long as the surface
stayed visible.
clearNestedRepoSelection now remembers the dropped repository per root
(session-only set, cleared on runtime switch), and auto-select skips
remembered paths. When every candidate has failed, no selection is made
and surfaces settle into their unresolved state instead of churning
requests. A manual picker pick remains possible and is probed like any
other.
The animated maintainScrollAtEnd also ran outside streaming, so opening
a historical session glided visibly through the whole conversation as
late row measurements corrected the end position, and an in-flight glide
could supersede explicit navigation. Corrections are instant unless the
session is actively working.
Returning to the bottom during a stream also lands on the end as of that
moment, and the list's own follow may not have re-armed after the user's
earlier gestures — the queued-send jump then fell behind the growing
reply. goToBottom now re-asserts the edge a few times (150/400/800ms)
until it holds; a new user gesture cancels the window.
Token-by-token streaming mutates the trailing paragraph in place on
every tick: words rewrap, the last line jitters, and the whole reply
reads as flicker. Streamed text now commits only up to the last complete
line — prose arrives a paragraph at a time (a markdown paragraph is one
logical line), code fences reveal line by line, tables row by row — and
a shown block never changes again. A paragraph that runs long without a
newline releases at the last sentence (then word) boundary so the stream
never stalls. Applies to assistant text and reasoning; tool output keeps
its raw tail.
With growth arriving in block steps, the end follow switches to the
list's animated mode so each step is a glide — reveal and scroll read as
one continuous motion. The gesture opt-out now measures at-end from the
live list state instead of the cached flag, which the animated glide
deliberately leaves stale while trailing the edge; without that, a drag
during a glide could leave the scroll-to-bottom pill unshown.
Measured: end-following holds at distance 0 for the whole stream, and
the mobile drag opt-out shows the pill in three of three runs. The
continuous glide costs ~15% more main-thread time per streamed character
than the instant follow — the price of the motion.
Sending from mid-history positioned the new turn with an animated
scrollToIndex; over a long distance the smooth scroll gets cancelled by
rows mounting and measuring along the way — a weak scroll attempt that
dies partway, leaving the reader where they were. Park instantly when
the viewport was not at the end at send time; the short smooth park
remains for sends from the live edge.
3.3.x makes maintainScrollAtEnd follow content growth on its own — a
tail row growing in place included — which is exactly what the manual
totalSize correction existed for. Delete that correction (the totalSize
listener now only drives the anchored-turn glide) and pick up 3.3.x's
measurement batching, prepend-flash fixes, and web programmatic-scroll
fixes. Opt the explicit maintainScrollAtEnd config into footerLayout per
the 3.1.1 guidance.
The library's own released-on-user-scroll heuristic proved unreliable
one run in three against synthetic touch, so the gesture state machine
stays authoritative: while a real gesture owns the scroll, the list's
end pinning is switched off through a threaded endPinningReleased prop
and re-engages when the user returns to the end.
Validated with the CDP battery on a production build: stream follow
stays at distance 0, mobile drag releases with the pill shown in three
of three runs, resize oscillation stays at the reduced level, the rail
reaches the last turn, and profiled streaming cost per rendered
character matches the tuned 3.2.0 numbers.
Extract GitView's resolution flow into hooks/useNestedGitDirectory (root
probe, discovery, auto-select, stale-selection recovery) so the flow no
longer depends on SessionSidebar probing the root first, and reuse it in
the pull-request view, walkthrough view, and mobile changes surface —
all three now operate on the selected nested repository instead of
dead-ending on a non-repo root. Shared pending/failed/unsupported/empty
states live in git/NestedRepoResolutionStates; desktop changes inherits
the behavior through GitView. Selection stays shared per root, so the
picker's pick carries across surfaces.
A slow edge drag settles the resize detector repeatedly, and every settle
replayed the instant return to the live edge — reading as exactly the
jumping the resize suspension removes. Stay where the reader is; geometry
changed, and the next explicit action or stream correction re-engages the
edge as usual.
A pinned viewport shook during window resizes while a scrolled-up one
stayed calm: both pinning mechanisms — the growth corrections and the
list's maintain-scroll-at-end — re-assert the end against rows that are
still re-measuring, fighting the size compensation that keeps the free
case stable. While the width is actively changing, stand both down and
hold the reading position the same way the free path does, then re-assert
the live edge once with a single instant write after the resize settles
(only when the viewport was still following).
On touch surfaces the gesture opt-out waited for the viewport to have
already left the end, but streaming corrections re-pin the viewport every
chunk, so the drag never registered: the user could not scroll away
mid-stream, the scroll-to-bottom pill (which waits for a gesture) never
appeared, and live-follow stayed armed — which also made the mobile
load-older button throw the viewport to the bottom, since the prepend's
content growth ran the end correction.
Track the finger's Y between touch events and treat a downward drag (the
touch mirror of wheel-up) as an immediate opt-out. Loading older history
also releases live follow explicitly before the prepend, covering the
case where the button is reachable without any prior scroll.
The old scroll engine needed message parts to report content growth
(onContentChange) and per-message animation lifecycle callbacks
(AnimationHandlers) so it could re-pin the viewport. The timeline list
measures growth itself now, and the replacement hook had already stubbed
the whole contract with no-ops kept only for source compatibility.
Remove it end to end: the hook exports, the container and list threading,
the ChatMessage/MessageBody signal-only effects, and every part-level
prop and call site. Expand/collapse behavior and reveal animations are
untouched — only the reporting channel goes.
New Streaming section on the Chat settings page with a checkbox that
controls whether the viewport follows new content while a response
streams. Default stays on. With it off, the anchored user message still
parks at the top on send, but no glide or end-follow correction runs and
the list's maintain-scroll-at-end stays disabled; the scroll-to-bottom
pill and session open keep scrolling explicitly.
Persisted through desktop settings like the other chat toggles (auto-save
diff, authoritative apply, sanitize), registered in settings search, and
localized in every locale.
Streaming follow scheduled a two-frame animation-frame chain per content
growth and then went through the list's programmatic scrollToEnd, whose
scroll bookkeeping schedules further frames. Together they roughly
doubled frame production for the whole stream (16 -> 35 frames/s in a
profiled streaming session), with the matching style recalc and layout
cost per frame.
In following-end mode, correct synchronously in the totalSize listener by
writing scrollTop directly — the same write path a user gesture takes —
and keep the animation-frame chain only for the anchored-turn glide.
Profiled streaming frame production returns to the previous engine's
range and peak heap drops from 164 MB to 90 MB.
The streaming status and the scroll-to-bottom pill now hand off to each
other in one place above the composer: same anchor, same input-aligned
column, both in the popover glass (ring and soft shadow stay pill-only
as the interactivity cue, with the shadow on a wrapper so the glass
backdrop-filter cannot drop it after hide/show cycles). The pill is a
single full-surface button, moves to the left edge, and while the
session streams it carries a compact one-line status label instead of
the status row's animation machinery, which did not survive a 32px
chip.
Behavioral fixes along the way: a gesture shows the pill immediately
(no debounce) and hides the status in the same frame; gestures register
whenever the viewport can actually move up — an anchored short turn
kept live-follow armed and suppressed the pill entirely; the status
row sheds its in-list morph offsets (mb-6, message column) and its
inline-size container gets the glass on an inner row, since a query
container cannot shrink-wrap; its working text uses full
muted-foreground to match the pill.
The animated glide trails its target between corrections, so the
distance-based at-end signal flapped during streaming and the pill and
the status row crossfaded into overlapping half-transparent mush. The
pill now shows only in free-scrolling (a real gesture), which also
keeps the status row's visibility stable; a gesture taken after our own
movement already left the end offers the pill immediately.
The phase-2 reveal correction now scrolls animated; successive
corrections restart the smooth scroll from the current position, so
streaming follows as one continuous motion. User gestures interrupt the
native smooth scroll, so free-scrolling is unaffected.
Phase 2 of the anchored turn (following the live edge once the reply
outgrows the viewport) never ran: the reveal correction was triggered
only by entries-array changes, and the streaming tail grows inside a
single row without touching the array. The hook now also subscribes to
LegendList's totalSize and re-runs the same guarded correction on every
content growth.
Six reported defects, one root theme — our port diverged from the
reference semantics:
- a user gesture no longer collapses the reserved anchored end space
(that snap-to-bottom was the 'anchoring vanishes on a micro-scroll');
it only disarms the follow/anchor machinery, and anchor remeasures
after the user takes over can no longer re-position the viewport
- the anchor arms synchronously BEFORE the send reaches the store, and
the claim compares against the arm-time baseline — arming a frame
after the optimistic row committed is why anchoring 'rarely worked'
and sending from mid-history did nothing
- gestures are selective (wheel up, touch/pointer away from the end,
PageUp/Home/ArrowUp), so scrolling toward the end or clicking a row
at the live edge no longer kills follow
- 'at end' is a tight 40px band against the full content length instead
of the list's half-viewport isNearEnd, so the scroll-to-bottom pill
appears when the viewport actually leaves the end
- reaching the end re-arms follow without knocking an anchored turn out
of its mode, and the overlay scrollbar suppression follows scroll
ownership rather than the anchor's mere existence
- the status/working row moved out of the list footer to a fixed spot
above the composer: inside the list it walked down with every
streamed line, and its unmount was part of the end-of-stream jerk
Sending a message now parks that message near the top of the viewport
and streams the reply into reserved end space below it, instead of
jumping to the bottom and chasing it.
- swap @tanstack/react-virtual for @legendapp/list in the chat timeline; the
streaming tail becomes a normal list row rather than a separately rendered
block, so one component owns the scroll position
- add timelineScrollAnchoring: pure anchored-turn geometry plus the three
scroll modes (following-end / anchoring-new-turn / free-scrolling)
- replace useChatAutoFollow with useChatTimelineScroll, which opts out of
automatic movement on real gestures via a generation counter instead of the
timer windows the old implementation needed to recognise its own writes
- move the load-older button, question/permission cards, recap, status row and
bottom spacer into the list header/footer, since the list owns its container
- extract useScrollShadow so the shadows can attach to that container
maintainScrollAtEnd and maintainVisibleContentPosition replace the manual
prepend anchor-hold and the mobile quiet-window prepend deferral.
Validated: workspace type-check, lint, web build, ui tests per file.
Scroll behaviour itself is unverified and needs manual testing on web, desktop
and iOS.
A new Session tabs group (web/desktop only) turns the header session
tabs off; disabled, the header renders the exact pre-tabs view — plain
session title with meta row and the always-visible session menu (the
same block VS Code uses). The Alt+W close-tab shortcut no-ops while
tabs are off. Registered in settings search with a matching anchor;
labels translated in all locales.
The close-with-neighbour-activation logic moved into a shared helper
(the strip and the shortcut use the same path). Alt+W is the default
because the browser owns Cmd/Ctrl+W on web; desktop users can rebind it
— the action is registered as customizable, so it appears in the
Settings shortcuts section and the shortcuts help automatically. VS
Code is excluded (it has no session tabs).