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:

  1. the current session id,
  2. the conversation snapshot (openState, hasMore, loadingOlder),
  3. 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 call session.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 via npm test.