The Ctrl+O hidden phase keeps one Assistant header per turn: the first
step with visible text/reasoning owns it, later steps render as
headerless continuations, and bodiless (tool-only) steps render
nothing. Leaving hidden restores per-step headers. Pure TUI
presentation; the session log is unchanged.
The selector called readSession per listed session under an unbounded
Promise.all: each call re-listed the whole persistence store (O(N^2)
listings), decompressed and parsed the complete log, replay-validated
every event, and deep-cloned it up to three times, only to derive one
row's title, activity time, turn label, route, and goal phase. On a
real 185-session / 87 MB store the selector took tens of seconds.
Candidate rows now come from one projectSessions batch over borrowed
logs; a rejected projection degrades to the same disabled unreadable
row. Preflight still replay-validates the single chosen session through
readSession, which is already live-preferred, so its redundant live
shortcut is gone.
Public SessionQueryService.projectSessions wraps the existing corpus
projectMany: one persistence listing, bounded persisted-inspect
concurrency, per-id failure isolation, and a synchronous projector over
a borrowed source with no replay validation or cloning.
readTitleSnapshots now routes through it; LogicalSessionSource and
LogicalProjectionResult are exported and documented.
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.
SearchRow (this PR) and FileMutationRow (landed on master) independently carry
byte-identical rowStateStatus + rowResultText helpers, which the duplication gate
flags once both are present. Extract both into contract/toolview-status.ts and
consume them from both rows, deleting the clone rather than nudging it under the
threshold.
Node >= 21 exposes a global `navigator` reporting the machine's own language,
so gating detection on `navigator` let a non-browser boot of the client tree
resolve to `en` instead of the documented fallback; `window` is the browser
test. `navigator.languages` is spec-required but absent on some embedders and
older WebViews, where spreading it would throw at boot, so the walk tolerates
its absence and `navigator.language` covers that host.
The per-spec pin boilerplate collapses into one suite-level
`usePinnedBrowserLanguages('zh-CN')`, which owns the rationale in
dsh-client-test-runtime, and the English-browser e2e scenario now clears the
console warnings channel too — its page has no closing inventory spec.
Both Workspace surfaces offered "Open local folder…" and "Create a new
workspace" for one outcome. The browse occupant already carries its own
New folder affordance, so picking a directory covered creating one; the
name dialog only added a second vocabulary and a create target the
operator could neither see nor choose.
The surviving entry is named after the outcome — "Add workspace…" — and a
menu now appears only where there is something to choose between: with no
Workspace listed (the add-only sidebar header, or an empty hero list) the
anchor gesture raises the directory flow directly instead of a one-row
popover. An empty list counts as final only after the list baseline lands,
and a composition with no directory-flow occupant hides the sidebar button
rather than offering a dead one.
WorkspaceCreateFlow becomes WorkspacePickFlow (createOnly -> addOnly) and
the injected createWorkspace narrows to { path }. The host's
workspace.create({ name }) branch and `dsh web --workspace-root` lost their
last product consumer; both are marked at the call site for a follow-up.
The keyed toolviews (read, file-mutation, web) each draw the same ToolRow
summary-line chrome; the read row's copy trips the duplication gate against the
file-mutation row. Extracting the shared row is a separate change tracked for
all rows at once, so this scopes the exemption to the read row's block.
A first visit resolved to Chinese regardless of the browser: LocaleService
read `dsh.locale` and fell straight back to `zh` when nothing was stored,
ignoring the languages the browser already states it reads.
The initial locale now resolves through three ordered sources — the persisted
preference, then `navigator` (first entry of the ordered language list whose
primary subtag names a shipped locale, so `zh-Hans-CN` -> zh and `en-GB` ->
en), then `FALLBACK_LOCALE`. An explicit choice still wins and nothing writes
the detected locale back to storage, so "has the user chosen?" stays a
question only the stored value answers.
Specs asserting the shipped Chinese copy now state the browser they assume:
the web e2e scenarios open their page with `locale: ZH_BROWSER_LOCALE`, and
package specs pin it through the new `pinBrowserLanguages` test helper.
`settings-chrome.e2e.ts` gains an English-browser scenario as the
assembled-app proof.
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.
Review follow-up: the llm/adapters-updated listener's disposer was
discarded, leaving it firing (harmlessly, behind isDisposed()) between
TUI shutdown and fiber disposal, asymmetric with the sibling channel
listeners. The controller now exposes detach(), and the channel's
detachListeners() calls it on both the dispose() and startup-failure
paths.
The TUI still renders the unlisted current model as its own row and marks it
current; Web shows the unset trigger label and asks for a replacement. The
note recorded the Host decision but not that the two surfaces present it
differently, which reads as a missed migration rather than a choice.
Also records why `trigger.selectAria` and `trigger.fallback` hold identical
strings, so a future coalescing cleanup does not merge them.
The previous assertion pinned the literal `bg-layer-3` fill that was just
reverted. What matters is the relationship it broke: `bg-layer-3` and
`bg-module-platform` both resolve to neutral-bluish-800 under the dark
theme, so filling the row with either erases the nested editor's boundary.
The typed text was held in one active buffer, so editing a second row
displaced the first — which then fell back to rendering its stored NaN as
the literal `NaN`, losing the text the row was supposed to let the user
correct. Reset dropped the override but kept the buffer, leaving typed text
over an inherited row that no settings layer stores, and an unreadable
buffer never settles, so it stayed there.
There is now one entry per row. `remove` re-keys around the dropped row so
text travels with its own row, and reset clears them all because the rows
they annotated are gone.
Model ids are compared trimmed as well: `" "` passed the required check,
and `"model "` slipped past the duplicate check against its own twin before
silently failing to match at the adapter. A pasted id settles on blur rather
than being trimmed per keystroke, which would stop an interior space.
The master merge added a required archivedSessionIds field to WorkspaceListState;
the read-card spec's DetailsPanel fixture state needs it to typecheck.
The card is a React child of the wrapper, so capture-phase presses on it
reached the wrapper's dismissal handler: the first pointerdown of a text
selection closed the card, contradicting its JSDoc contract. Restrict the
immediate close to presses outside the card, keeping it mounted under a
held press (and the browser click with it), and align onPointerLeave's
grace arming with Menu (only while open).
Pin both new behaviors in hover-card.spec and update the bilingual Agent
Note.