This PR moved the 14-line cap declaration off the auto-grow mirror and onto the box that scrolls the draft, and retargeted the approval scenario's cap probe from the textarea to that box. The sibling note stated both as they were; implemented notes have to state what shipped, in the change that ships it.
A caret straight after a newline is on a line with nothing to measure — the shape a trailing-newline draft ends in. chromium returns no client rects at all for the collapsed position (an all-zero box, which sent the reveal upward instead), firefox reports the line above, WebKit the right one. Measure the newline the caret just left and step one line down: all three then land on 649 of 652 with the caret's line at 315 inside the 336px box. The browser case now pastes a newline-terminated block, and fails 'expected 0 to be greater than 0' without the rule.
The reveal spec wraps setStart to record which layer and index the composer measures; capturing the method directly trips the unbound-method rule, which the pre-commit lint caught after a --no-verify commit slipped it through.
preventScroll stopped the browser from revealing a focus nobody gestured for, and took the caret with it. The composer DOM is reused across sessions, so switching to a longer draft keeps the previous offset while the value swap puts the caret at the new draft's end: measured on all three engines, 940px below a box sitting at 0, where the old geometry reached the caret at 628. The unlock effect now suppresses the browser's walk and reveals the caret in its own scrollport, landing at 625 of 628. The focus-keeping mousedown gets preventScroll too and needs no reveal. revealCaret returns early when the box cannot scroll: the draft fits, so every caret is already in view.
Pasting a long block left the view where it was while the caret sat at the
end of what was pasted. Paste, ctrl/meta-Enter newline and cut all suppress
the native edit — the machine owns the draft and the undo log — and restore
the caret with `setSelectionRange`, which reveals nothing: measured in
chromium and WebKit, before this branch as well as on it. Firefox happened to
reveal it, in the old geometry only.
The three restores now share one helper that measures the caret against the
hidden mirror — same draft, same metrics, same wrap width, so a Range
collapsed at the caret's index reports where the caret is without a caret API
— and scrolls the scrollport the minimum that brings the line inside, which
is what the browser does for typing. One scrollport is what makes this
possible at all: the reveal is finally a single offset to move.
Also from review: the composer's own focus() on unlock and session switch
passes preventScroll, so a session switch cannot move the transcript through
the taller textarea's reveal chain.
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
session-telemetry-otel owns the identity as its own module (src/user-id.ts):
getOrCreateAnonymousUserId() returns the bare UUID line in $DSH_HOME/.userid,
minting a random UUID v4 on first use — synchronous IO with a process-lifetime
memo keyed by resolved path, an exclusive-create (wx) settle for concurrent
first launches, best-effort on read-only homes, and deletion resetting the
identity on the next launch by design.
The backend carries the id as the Resource's user.id (the OTel semconv user
attribute), once per export batch alongside service.name/service.version.
The identity belongs to the OTel feed alone; the dsh-sdk launcher telemetry
keeps its own separate store.
A blank row is a provisional placeholder: nothing has happened in it,
so the row verbs (rename/fork/archive) and a 'now' stamp would act on
content that does not exist. The trailing cells and the hover card's
time line stay off until the first prompt lands.
Public snapshot state stays in the store engine's plain-data vocabulary
(immer drafts reject Sets without the MapSet plugin, which stays off):
manager/service/contract carry readonly SessionId[] in Host order, and
the tree derivations build their own transient Set — the
expandedProjects pattern. Membership-unchanged installs still keep the
array reference for Object.is short-circuits.
- WorkspaceRegistry.archiveSession no longer wraps persistence-listing
failures as WorkspaceUnknownSessionError: only a definite miss (live
lookup, header index, then a fresh list) maps to session-not-found;
storage faults propagate as internal errors, with a negative test.
- The archived-current sweep moves from the unary path into the
projection: any install path (local echo, another tab's frame, a
reconnect baseline) clears a selection that landed in the archive set.
- An archive set installed while workspace.list is in flight supersedes
the stale baseline's set instead of being rolled back by it.
- The workspace-management e2e anchors the archived row by its session
actions button and asserts the single-stray fixture assumption loudly.
- Drop the stale touchSession rows from the workspace READMEs (the
method was removed with its Agent Note).
The visual-only Delete session placeholder becomes a wired Archive
session action: no confirmation dialog (non-destructive), failures stay
console diagnostics. tree.ts hides archived sessions in every
derivation (workspace groups, Ungrouped, search, flat list) through the
sessionVisible predicate. The workspace-management e2e pins the archive
round trip across reload.
WorkspaceListState gains archivedSessionIds (ReadonlySet, replaced only
on membership change), installed as full snapshots from the list
baseline, the unary echo, and the changed frame. Archiving the current
session clears the selection into the New Session view state. Test
doubles (test-runtime, fake APIs, fixture client) follow the widened
IWorkspaces/IApiClient faces.
workspace.archiveSession answers the full updated archive set;
workspace.list carries the set as the reconnect baseline; the host
stream pushes host/archived-sessions-changed full snapshots from the
domain/changed global-put branch (same posture as workspace-changed).
Unknown sessions map to the existing session-not-found code.
The workspace domain global singleton gains archivedSessionIds (schema
default [], version unchanged): a display-layer archive set layered over
workspace accounting. archiveSession() rides the registry operation
chain, validates the session against live/persisted headers, and is
idempotent; archived sessions keep their sessionIds slot so a future
unarchive restores position.
Review flagged that "equal by construction" rested on an engine behaviour I
had not measured: `scrollbar-gutter: stable` only equalizes the layers if the
engine applies it to `overflow: hidden` the way it does to `overflow-y: auto`.
Measured it on the running app across the three engines Playwright ships,
and the property does not hold up.
engine .input / .backdrop / .mirror wrap width
chromium 776 / 776 / 776 (768 / 768 / 768 with the declaration)
firefox 776 / 776 / 776 (unchanged by it — overlay scrollbar)
WebKit 768 / 776 / 776 (unchanged by it)
WebKit reserves for `overflow-y: auto` and not for `overflow: hidden`, so the
declaration left .input at 768 against 776 — exactly the gap it was meant to
close — on the one engine where that gap is observable at all, while costing
every chromium user 8px of text column unconditionally. Reverted: the
composer's metrics are now the same as before this PR.
The WebKit gap predates this change and is not closed here. It is recorded in
the Agent Note with the numbers, and the browser scenario asserts the equality
on the lane's engine so a regression into that state fails loudly. The mirror
is unaffected on WebKit for the drafts measured — the extents still agree — but
a draft whose wrapping turns on those 8px would clamp it.
The review's monotonicity concern resolves the same way: the declaration was
never worse than master, because WebKit already measured 768 against 776
without it. It simply was not better.
Also from this round: the wrap-width assertion now covers .mirror as well as
the two glyph layers — it is the height authority, so a mirror alone wrapping
wider would measure the box short and clip content below the 14-line cap with
every other assertion green. Plus `renderGeometry`'s missing `@param
trailingNewline`, and both e2e tsconfig lists restored to alphabetical order.