Design notes
August 22, 2026 · View on GitHub
Goal
In DSH Web, opening a session pulls only the latest history page
(session.history with maxMessages: 50, hardcoded in the client runtime).
Older messages require clicking the "Load earlier" button once per page. This
plugin removes that manual paging: the moment a session opens, the full
history loads itself.
The host RPC (session.history) already accepts an arbitrary maxMessages,
but the client runtime fixes the page at 50 and exposes no setting for it, and
the official UI offers no configuration for this behavior. A plugin therefore
cannot change the page size — but it can drive the client's own
Session.loadOlder() verb repeatedly until the window is complete.
Why conversation.composer.dock
The plugin is pure client-side and needs three things:
- the current session id,
- the conversation snapshot (
openState,hasMore,loadingOlder), - a lifecycle that mounts/unmounts with the session.
The official session-scoped slot conversation.composer.dock (a list slot,
replaceRisk: none) provides all three through its standard props:
sessionId and useSession (a SnapshotSelectorHook — it takes a selector
and returns the selected value; the whole snapshot is selected with
(s) => s). Registering a component that returns null gives a hidden,
session-tracked hook point with no UI footprint.
The cascade
The component's effect runs on [sessionId, snapshot]:
openState !== 'open'→ wait (session still opening, cold, or error);hasMore === false→ done (the whole history is in the window);loadingOlder === true→ wait (a page is already in flight);- otherwise resolve the session binding (
sessions.binding(sessionId)) and callsession.loadOlder().
loadOlder flips loadingOlder and marks the snapshot dirty, so the snapshot
changes immediately (guard: loading) and again when the page lands (guard:
open, next page). The cascade is therefore snapshot-driven, strictly
serialized, and self-terminating: hasMore becomes false on the final
page (the runtime also forces it false on an empty page, so a gap cannot loop).
No-progress guard
loadOlder swallows transport errors internally (logs and resets
loadingOlder), so a persistently failing host would otherwise re-trigger the
effect forever. The plugin tracks per-session progress by the in-window node
count (snapshot.nodes.length): three consecutive effect runs that fired a
loadOlder without growing the window stop that session's auto-loading. The
user can still click the shipped "Load earlier" button; the plugin just stops
retrying. A thrown/rejected loadOlder is additionally contained at the call
site so it can never propagate into the React render tree.
Settings (opt-in, localStorage)
Both settings are read through the pure core (settingsFrom) on every effect
run; invalid or unreadable values always fall back to enabled + unlimited:
dsh-autoload-history.disabled = '1'— stop auto-loading entirely;dsh-autoload-history.maxPages = '<positive int>'— cap how many pages one session open may auto-load (default unlimited). The per-open counter resets when the session leaves the loadable state (closed / fully loaded).
Lifecycle & safety
- The slot injection and registration are owned by the plugin fiber — unload (stop/update/remove) disposes everything.
- Switching sessions remounts the slot entry for the new session id; each session gets its own guard record.
- No host half behavior, no network requests, no global DOM mutation; only
opt-in reads from
localStorage. - Renders nothing; the only observable effect is history being loaded.
- The load decision lives in
src/core.mjs, unit-tested vianpm test.