The chat screen relied on the browser's focused-field reveal, which holds
on iOS Safari but not on Android, where interactive-widget is also widely
ignored — the composer just stayed behind the keyboard. The draft screen's
visual-viewport pin now covers the chat screen on Android; iOS chat keeps
the native reveal.
Removes the transient aborted banner from the composer status area
Simplifies status row rendering to focus on working state and pending changes
Cleans up unused abort-status localization strings
Add to input leaves the desktop menu (mod+L owns it; mobile keeps the
button) and the New session action is gone from both variants along with
its handler and dead locale keys.
Every response already funnels through runtimeFetch, so a classifier there
spots 401s, confirms them against /auth/session (a proxied provider 401
must not read as a logout), and flips a small auth-session store. The web
and hosted surfaces show a frosted banner under the header whose Log in
button hands off to the session gate's existing unlock flow; sends are
paused while expired, the session-load error screen explains the auth case
and retries itself after login, and returning to a long-idle window
revalidates once via visibility/focus. Native mobile feeds the same signal
into its connection re-probe instead of showing the banner; VS Code is
exempt.
The failure handler restored the text only for a new-session draft; a
regular session kept its attachments but lost the prompt to a toast. The
restore now runs before any cause-specific branching: an unchanged composer
gets the text (and the session draft) back, new typing gets the failed
prompt appended instead of clobbered, and a mid-send session switch writes
it into the originating session's persisted draft.
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.
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.
- Markdown DOM cache: key includes a content-length fingerprint so an
edited or reverted part re-materializing under the same id cannot
restore stale DOM, memoization uses scalar identities instead of the
part object (store reducers recreate part objects on unrelated updates,
which re-ran the async render pipeline for identical content), and a
probe with a mismatched locale/directory no longer destroys the entry
it failed to claim.
- Sidebar bootstrap: the layout-level sync owner only knows known
directories, so expanded projects bootstrapped serialized at background
priority. The visible collection now publishes a second, expansion-
aware demand owner, restoring concurrent hydration for expanded
projects and worktree groups.
- Settings: local changes still sitting in the debounce buffer are not
yet tracked as mutations, so a settings GET racing the debounce window
briefly reverted them; reconciled results now reapply the pending
buffer.
Main replaced the chat timeline scroll engine while this branch was in
flight, which obsoletes two of its subareas and reshapes a third:
- Chat timeline: main's LegendList-based MessageList/ChatContainer win;
the activation-overscan staircase targeted the removed tanstack path
(LegendList provides adaptive rendering natively) and is dropped along
with its test.
- Scroll shadows: main's hook-based masks stay (the virtualized list owns
its scroll element — there is no wrapper to hand the styling to); the
viewport-wrapper ScrollShadow rewrite, its index.css replacement, its
test, and the call-site viewportClassName adaptations are reverted to
main. The chat OverlayScrollbar keeps this branch's disableHorizontal.
- OverlayScrollbar: the direct-DOM rewrite lands, but binding now follows
the live container node instead of binding once per ref object — the
chat scroller remounts on every session switch, and a bind-once
contract left the scrollbar attached to a dead element.
- Markdown renderer: the detached-DOM cache and warm-block fast path
merge with main's block-commit reveal (enter cascade), streaming code
highlighting, and gutter reservation; the per-block reconcile keeps
both the decoration-refresh path and the reveal cascade.
The composer rendered a second StatusRow instance carrying the
pending-changes bar and the todos dropdown, so every restyle of the
floating assistant-status chip (glass, placement, sizing) silently
restyled the composer bar and its dropdown too — for the fourth time.
StatusRow is now only the floating chip above the composer; the
composer's own bar is a new ComposerStatusBar with the pre-glass layout,
its own container name, and its own container-query classes (the
anti-overlap rules that hide the long active todo under 38rem and the
changed-files label under 30rem moved with it — losing them during the
split let the two dropdown triggers overlap on mobile). The two
components share no markup and no CSS hooks anymore.
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.
The 140ms fade read as a pop next to the scroll glide. The reveal now
runs 320ms on an ease-out-quint curve with a 10px rise and a touch of
blur resolving — paint-only properties throughout, so row measurement is
untouched — and blocks committed in the same tick cascade with a 55ms
stagger instead of appearing together. Reduced motion still disables the
whole thing.
Retain detached Markdown DOM for eight recent sessions so medium-frequency session switching can restore more first-screen content without reparsing it.
Limit each session to four cached parts, reducing the worst-case retained fragment count from 36 to 32 while preventing long sessions from dominating the cache.
With block-level commit the open code fence grows by whole lines at the
throttle cadence, so the old reason to skip highlighting it (re-tokenizing
a growing block ~40x/sec) no longer applies: a partial fence now
highlights too, and streamed code arrives colored instead of colorizing
only when the fence closes. Fences beyond 300 lines fall back to plain
text until closed, keeping the repeated worker re-tokenization bounded.
A freshly committed block also enters with a short fade-and-rise. The
class goes on the block's children (the wrapper is display:contents and
cannot animate) and the transform is paint-only, so virtualized row
measurement is unaffected; reduced motion disables it.
Verified over CDP on a production build: streamed code inside a still-
open fence renders highlighted, and the code text's x-position is
identical during and after the stream.
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.
Three first-render polish issues:
Code blocks jumped at end-of-stream: streaming defers the per-line
line-number markup, so the finished decorate pass inserted the gutter
column and shifted every code line right. The gutter's horizontal
footprint is now reserved with CSS while the markup is deferred, so the
final pass only fills in numbers and colors.
File references were verified twice per render and re-verified with the
wrong directory: the annotation pass's own DOM writes re-triggered its
MutationObserver, and the pass also ran before the effective directory
resolved — issuing stat probes under an empty directory and a second
time under the real one. The observer now ignores the pass's own
mutations and annotation waits for a resolved directory.
Content replacing the hydration skeleton popped in: it now plays a
one-shot 180ms fade (reduced-motion aware); cached session switches
never carry the class and stay instant. Also removed the dead
disableStaging prop and its unused pendingRevealWork threading.
Verified on a production build over CDP: a streamed code block's text
keeps its exact x-position across end-of-stream, and stat probes for a
message with file mentions are unique per path with the directory header
always present. The hydration fade path could not be exercised in the
harness (the live event stream pre-populates parts in a single-project
environment) and needs an eyeball check on a cold multi-project open.
Sending while the agent is working queues the message without creating a
user row, so the anchor-arming send path had nothing to claim and the
viewport stayed parked mid-history. Queued sends now jump straight to
the live edge (instant, so end following re-latches immediately).
Rail selection also switches from smooth to instant scrolling: a long
smooth scroll through the virtualized timeline gets cancelled by row
remounts and lands mid-way or on the wrong message, while a teleport
always arrives.
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.
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).
Both scrollToTurnId and scrollToMessageId hard-returned false when the
target was the trailing (last) turn and its element was not mounted, so
clicking the rail's last item in a long scrolled-up session did nothing.
The trailing entry is a regular list row at the end of the data — scroll
to its index the same way history targets are reached.
A width change re-wraps every row at once, and with size restoration off
the accumulated height delta above the viewport threw the visible content
hundreds of pixels up and down while dragging the window edge. Size
restoration must stay off in steady state — a tool result expanding in
place has to grow downward — so a ResizeObserver on the scroll node
enables maintainVisibleContentPosition size compensation only while the
width is actively changing and releases it 300ms after it settles.
Measured on a continuous 1400-800-1400 drag over a long session pinned to
the live edge: the largest per-step displacement of a visible paragraph
drops from ~680px (median of three runs) to ~200px, and the viewport
never detaches from the end.
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.
The streaming tail only overlaid the actively streaming message with live
parts from the sync store. When a turn moved to its next step message, the
previous message fell back to its lagging base record, briefly dropping its
completed tool parts — remounting them and replaying their reveal
animation once the record caught up.
Overlay live parts for every message of the streaming tail via a new
useSessionPartsForMessages hook, guarded so an empty live array never
erases parts the record does have. Also key generate-effect glyphs by
index so appended text does not replay earlier characters, and keep tool
row reveal wrappers mounted unconditionally.
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.
Away from the live edge the floating status row fades out (it was
transparent text over scrolled history) and the scroll-to-bottom pill
takes over the activity signal with a pulsing dot. The pill moves to
the left edge — matching the status row it stands in for — and gets the
same glass treatment as the dropdowns.
The rest gap after completion doubled up: the status overlay reserved
its own end inset on top of the footer's 10vh spacer. The overlay now
floats within the spacer zone — its height feeds only the glide target
(the live line still rests above it), the list gets no extra inset, and
completion cannot shift layout because there is nothing to collapse.
The finished message's metadata lands in the slack the glide left above
the status row, restoring the old morph-in-place feel. The frozen
height hack and the opaque background are gone — the overlay only ever
covers blank spacer, not text.
The barely-visible shift at end-of-stream was the reserved status-row
inset being released when the row unmounted. The measured overlay
height now only grows during a session and resets on session switch, so
the finished message's metadata footer appears exactly where the status
row was, with zero layout movement.
The floating status/working row let streaming text show through it and,
because the scroll math did not know the overlay existed, the reveal
correction targeted the very bottom edge — the live line ended up
hidden beneath the status row ('catches up to almost-the-end'). The
overlay now has a solid background, its height is measured with a
ResizeObserver and fed to the timeline as the occluding overlay height,
so the live streaming line glides to rest just above the status row and
the list reserves matching end inset at rest.
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.
Right-click now opens the session menu under the cursor (the sidebar's
context-menu pattern; the same header-supplied items back both the
"..." dropdown and the context menu via injected menu primitives) and
still never changes the active tab. Hovering a tab shows the sidebar's
session tooltip — title, last-activity time, project, branch and PR
status — after a delay, suppressed while a menu is open or a drag is in
flight. Each tab carries the sidebar's status dot at its end (accent
while the session runs, info-blue for unread), hidden while the hover
controls overlay it. Tab titles fade out instead of ending in "...",
and while the active tab is renaming its hover controls stay hidden so
only the rename controls show.