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.
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.
activeSurface was permanently 'chat' after the legacy mobile layout
removal, so the whole concept is gone: the store field, surfaceGuard,
setActiveSurface/setSurfaceGuard, the per-runtime surface memory in
prepare/restoreForRuntimeSwitch, and WorkspaceSurface itself. All ~30
setActiveSurface('chat') call sites were no-ops and are deleted;
always-true 'is the chat active' checks in keyboard shortcuts, Header
and ChatContainer are unconditional now. FilesView's dirty-file guard
kept its file-switch and close protection but drops the surface-switch
branch nothing could trigger. TerminalView visibility comes only from
its callers. The router keeps parsing legacy ?tab= links (they open the
matching context-panel surface) via its own RouteTab type and no longer
serializes a tab or diff file into URLs — desktop URLs never carried
them anyway.
MainTab/activeMainTab/setActiveMainTab/setMainTabGuard were deprecated
mirrors of the surface names — every call site now uses
activeSurface/setActiveSurface/setSurfaceGuard directly and the aliases
are gone, including the persisted mirror field.
The 'diagram' surface had no way to open it (navigateToDiagram had no
callers except a .drawio attachment click that navigated to a surface
nothing rendered); the surface, DiagramView, and its store plumbing are
removed, and a .drawio attachment now opens in the file panel.
?tab= deep links map to the matching context-panel surface instead of
setting a main-area surface nothing renders, and a persisted non-chat
surface can no longer rehydrate into a blank main area.
Branch, project, agent, model, provider, stash, SSH-host, skill-catalog
and archive filters each had their own toLowerCase().includes (or no
ordering at all); the git branch and gitmoji pickers also let cmdk
re-filter and reorder on top of the manual filter, silently dropping
rows. All of them now go through rankByQuery/matchesRankQuery: results
are relevance-ordered, multi-word queries match in any order, matching
ignores punctuation, and cmdk filtering is disabled where the ranked
list is already final. rankBranchesForQuery keeps relevance order
instead of re-sorting matches alphabetically; the model picker now also
matches model ids.
Directories and files were rendered as fixed category blocks, so an
exact file match sat below unrelated directories. Merge both result
sets and rank them with the shared fuzzy scorer against the full
relative path. Multi-word queries now match tokens in any order
(longest token queries the server, the rest filter client-side), and
path truncation keeps the parent segments next to the file name so
index.md-heavy trees stay distinguishable.
The chat selection menu (desktop pill and mobile sheet), the chat
comment input, the file-preview Comment pill, the shared inline comment
editor, and the composer's context chip preview popover now use the
same translucent blurred glass surface as the btw panel, keeping their
borders and soft shadow.
The project knowledge panel's section sidebar becomes a horizontal pill
strip on mobile, where a half-width side column left no room for
content. The autocomplete popups get their 120px minimum height back —
browser keyboard panning could put the anchor above the measured
boundary and collapse them to nothing. The status row's 24px footer-swap
reservation now applies only to the assistant-status instance; the
accessory-only row above the composer (pending changes) takes the
normal 8px instead of floating a stray gap.
Selecting text in a rendered markdown preview shows a Comment pill;
attaching stores a file-quote context draft carrying the file path, the
selected fragment (not whole lines), the user's comment, and a
best-effort source line range resolved by anchoring the fragment's
first and last lines in the raw content — a partially located fragment
gets no range rather than a misleading one. The fragment stays
highlighted while the comment input is open, using the selection
overlay rects shared with chat quote comments, and the preview's
native selection color now matches chat messages. file-quote flows
through the same context contract: composer chip previews, the message
context card, and the metadata round-trip.
The diff comment editor is restyled as the shared comment pill (context
line inside the box, auto-growing textarea, round attach button;
Cmd/Ctrl+Enter attaches, Enter breaks the line) and the saved-comment
card matches the composer preview entries. Pierre's gutter utility is
enabled: hovering a line shows a small primary-colored plus that opens
a comment for the line, dragging from it selects a range, and it rides
the bottom of an active selection. Clicking a diff line toggles a
single-line comment, and dragging over content maps to the same line
selection the number column produces — the native text selection is
suppressed once a drag crosses a line boundary — opening the editor for
the range on release. Placeholders and meta lines are dimmed relative
to typed text.
Hovering (or tapping) a context chip opens a stacked preview of its
pending items above the composer: numbered entries with a muted header
band, the captured selection, and the user's comment, which can be
edited in place (save/cancel) or removed before sending. The chips
component now subscribes to the draft store itself; the per-kind count
plumbing in ChatInput is gone.
Every user-attached context item (diff/file/plan comments, terminal
selections, browser annotations, PR comments and failed checks, linked
issues/PRs, and new chat-quote comments from the selection menu) is now
sent as its own synthetic text part carrying an openchamberContext
metadata payload. The model-facing text keeps the previous wording; the
timeline reads the metadata back and renders each item as a context card
instead of raw prompt text. Legacy messages still render via the old
text sniffing.
The selection menu gains a Comment option with an inline multiline
input, the quoted fragment stays highlighted while commenting, and on
mobile the input overlays the composer pill by rendering inside the
composer form. Add to chat is renamed Add to input; the menu is
restyled and the mobile Copy tile removed. Terminal drafts move their
terminal id out of the language field (persisted-draft migration v3),
and the dead preview-console source is deleted.