# dsh-ui-fortifier Current State ## Introduction A DSH Web UI enhancer: adds extra interaction capabilities to the official Web interface without modifying dsh source. ## Overview The plugin has a Host half and a client half. The Host half exposes the "Open dshHome folder" Remote service and declares that this plugin ships its own configuration page; the client half registers that configuration area on the bundle's own card on the Plugins page (`plugins.bundle.config`), reads the configuration entries to generate toggle rows automatically, and the index centrally controls each feature module's mounting (see "Module control mechanism"). Toggle state uses the dsh settings service three-layer resolution: schema defaults → `base` layer (plugin config cordis.yml defaults) → `user` layer (the user's override in the configuration area). All six toggle fields are declared volatile, because the settings form projects only volatile fields. The configuration form is read by **entry id** (`ui-fortifier`) while the configuration area is dispatched by **package name** (`dsh-ui-fortifier`); the two keys are not interchangeable. Each half has its own compiler face (`tsconfig.host.json` sees node globals, `tsconfig.client.json` sees browser globals) with the root `tsconfig.json` as a solution-only aggregate; types shared by both halves live in `src/types.ts` (types only, no runtime code), so the client face references no host file. Service methods run under the **caller's Context**, so the client `inject` must declare `sessions` / `remote` / `remote.session` exactly as the official `ui-model-selection` does: `ctx.modelDirectories.directoryFor()` reads `this.ctx.sessions` / `this.ctx.remote.session` internally, and an undeclared one raises `cannot get property "remote.session" without inject`. ## todo - open-dsh-folder sidebar compatibility: make the button a split button whose regular part opens with the system and whose indicator part opens in the sidebar; under a remote dsh keep only the sidebar open; switch between the two presentations by whether a sidebar plugin is currently loaded. ## Features ### Configuration area - Description: Provides a configuration area on this plugin's own card on the Plugins page, toggling all feature modules. Toggle rows are generated by reading the `ui-fortifier` entry's configuration. - Files: [src/client/settings/index.ts](../../src/client/settings/index.ts), [src/client/settings/FortifierSettingsPage.tsx](../../src/client/settings/FortifierSettingsPage.tsx) - Solution: The client reaches this entry's form through `ctx.configForms.get('ui-fortifier')` and reads the namespace value from the form snapshot; the toggle row list is driven by the explicit `MODULE_ORDER` array (`src/client/index.ts`) and rendered in the order the modules sit on screen (Session header → composer row → Plugins page); writes go through the form's write API and the toggles use the official `Switch` primitive. - Approach: Adding a feature requires adding a field to the Host `Config`, an entry in `modules`, and a copy key (`config.`) in `locales.ts`; the configuration area shows the new toggle automatically, with no change to its code. - Optimistic updates: a toggle is a controlled control, so a click lands on a local value first and is then written to the Host, whose answer serves as confirmation; the Host is not followed while writes are in flight, and is adopted after settlement only when it genuinely differs from this side (an external change, or a refused write). - Layout constraints: a 0.5px divider separates adjacent options. ### provider-label - Description: Shows the currently selected provider to the left of the model selector (`conversation.input.right` slot). - Files: [src/client/provider-label/index.ts](../../src/client/provider-label/index.ts), [src/client/provider-label/ProviderLabel.tsx](../../src/client/provider-label/ProviderLabel.tsx) - Solution: The component subscribes to the model-directory snapshot via the `useDirectory` inject hook and renders the current-provider field. - Approach: Data source is the current-provider field of the ModelDirectory store. ### model-picker - Description: Provides a cascading model picker in the input tool row (`conversation.input.right`, right of provider-label): providers on the left, that provider's models on the right, solving the difficulty of locating a model when the official grouped list has many providers. - Files: [src/client/model-picker/](../../src/client/model-picker/) - Solution: Registers the `conversation.input.right` list slot (`order: 1000`); the component subscribes to the official shared directory snapshot via `hooks.directory`, with `load`/`select` riding the same `modelDirectories` instance, so state stays in sync with the official selectors (button and slash command). - Panel placement: mounted through `createPortal` into `document.body` with `position: fixed` (`z-index: 1100`), so the columns' `overflow: hidden` clips and the sidebar no longer apply to it; coordinates come from the local `useRightAlignedMenuPosition` — right edge aligned to the trigger, clamped only when that would leave the viewport (the official `useAnchoredPosition` places from the left edge only and exposes no alignment option; the official `ModelSelect` mirrors that logic for the same reason). - Panel material: owned by the official `MenuSurface` (default radius tier), so this plugin's CSS keeps only layout and shadow; the surface itself never scrolls — scrolling stays inside the two columns, or the material's absolutely positioned layer would scroll out of its own box and let content behind show through. - Narrow-screen adaptation: the minimum width is `min(420px, calc(100vw - 32px))`, shrinking with the viewport; model names wrap in full via `overflow-wrap: anywhere` instead of being truncated. - Scroll behavior: on open, the current provider and current model scroll into view before paint (`useLayoutEffect`), positioned once per open; switching providers returns the model column to the top; switching back to the current model's provider positions to the current model. - Approach: The official `ui-model-selection` is untouched (single slot with no child slots to inject into; swapping the whole seat would outgrow official updates); reusing the shared store avoids a plugin-private copy; panel open/close is entry-local state. ### session-id-copy - Description: Adds a copy-Session-ID button to the Session header's utility group; the hover hint shows the session ID itself, clicking writes it to the clipboard, and the button briefly swaps to a check mark. - Files: [src/client/session-id-copy/](../../src/client/session-id-copy/) - Solution: Registers the `conversation.session.header.utilities` list slot (`order: -20`, leftmost in that slot); the button is a 28px-tall pill holding an inline hash glyph beside a copy glyph; the session ID comes from the slot's owner parameters (`scope: 'session'`) and is shown as `Session ID: {id}` on hover/focus through the `Tooltip` primitive; copying uses ui-primitives' `writeClipboard` (async Clipboard API first, `execCommand` fallback). - Approach: The three-dot menu (the one holding "download session log") builds its items from a hardcoded array with no injectable slot, so this is an adjacent button rather than a menu item; the dsh repository's `session-log-export` is not modified (harness source is overwritten by dsh upgrades); the feedback mirrors the official `MessageIconActions` brief check-glyph swap; the hash glyph is inlined because the official icon library has no identifier glyph, and the hint uses the `Tooltip` primitive rather than a native `title` (it also covers keyboard focus and never stacks two hints). ### compact-button - Description: Adds a compact-context button at the right end of the composer tool row's left group (after `+`, the paperclip, and the mode selects); a click opens a confirmation popover and only its confirm submits the `/compact` command. - Files: [src/client/compact-button/](../../src/client/compact-button/) - Solution: Registers the `conversation.input.left` list slot (`order: 100`; the slot had no occupant before); the button is a 28px circle sharing the left group's `+`/paperclip material; a click opens a confirmation popover and only its confirm submits through `ctx.remote.commands.execute(sessionId, '/compact', [])`, the same command channel the official `/plan` control uses; the glyph is an inline compress shape (supplied by the user, `viewBox 0 0 1024 1024`, 16px, filled with the button's own colour); the hint uses the `Tooltip` primitive, suppressed while the popover is open so two layers never stack. - Confirmation popover: one confirming question (`Confirm compaction?`) and one confirm button; no cancel button — an outside click or Escape cancels. It is portaled into `document.body` with `position: fixed` (the column's `overflow: hidden` cannot clip it), its material comes from the official `MenuSurface`'s compact tier, and it is placed by the official `useAnchoredPosition` (`side: 'top'`); that hook measures the panel in the same frame it opens, so the panel must mount first at a hidden measuring position and only then be placed — otherwise its height reads as zero, the viewport clamp and the `ResizeObserver` correction never engage, and the popover grows downward off screen. - Disabled conditions: disabled while `useSession(snapshot => snapshot.running)` is true (turning busy also collapses an open popover), and while a submission is in flight. - Focus retention: `mousedown` suppresses the default action so the caret stays in the editor after a click (every other button in that group does the same; without it a click steals focus and the draft in progress loses its caret). - Approach: It reuses the official command channel instead of reaching for the compaction capability directly, because that command is the definition of "same as the compact command", and the Host writes the `command/run`/`command/done` lifecycle into the session log with the outcome rendered as a conversation node, so the button never restates success or failure; the confirmation popover exists because compaction rewrites history with no undo path, so one stray click would be irreversible; the running state disables it because manual compaction requires an idle agent, and leaving it live would only hand the user an inevitably failed node. The glyph is inline because the official icon library has no glyph meaning compress, and it renders at 16px — the official icon set's default — rather than the 14px its two row neighbours use, because this artwork is denser and its detail smears together at 14px. ### open-dsh-folder - Description: Adds an "Open dshHome folder" button to the Settings header action area that opens `$DSH_HOME` in one click. - Files: [src/client/open-dsh-folder/](../../src/client/open-dsh-folder/), [src/open-dsh-folder.ts](../../src/open-dsh-folder.ts) - Solution: The Host side provides a TypertRemoteService (`uiFortifierRemote`) exposing an `openDshFolder` remote method, auto-mounted on the `/api` gateway through `ctx.reflect`; the client calls it via `connection.rpc.call('/api', 'uiFortifier/openDshFolder')`, and the button registers into the `settings.action` slot only when `connection.isLoopback`. - Windows foreground activation: the shared `openNativePath` runs only `Invoke-Item`, and since explorer creates the window while the host runs in the background, Windows refuses to bring it forward and it lands behind the browser (dsh's own button behaves the same). The Windows branch therefore runs its own PowerShell: after opening, match that folder's explorer window by `LocationURL` to read its `HWND`, synthesize an Alt press/release to become eligible, then call `SetForegroundWindow`. Non-Windows platforms keep the shared `openNativePath`. - Approach: The host endpoint reuses gateway reflection so no typert generator is needed; the remote result carries `opened`/`path` for button feedback and error copy; foreground activation reuses the Alt-synthesis technique harness already validates in its native folder dialog, adding an explicit raise because the target window belongs to explorer. ### settings-frame - Description: Makes the Settings panel draggable and resizable; after browser window changes the panel always stays within the viewport, with geometry persisted to localStorage. - Files: [src/client/settings-frame/](../../src/client/settings-frame/) - Solution: The component registers in the `settings.action` slot, locates the panel on mount via `closest('[role="dialog"]')` and switches it to `position: fixed`; the drag handle is portaled into the header row's first slot and the resize handle into the panel's bottom-right corner (pointer capture + rAF throttle); minimum 480x320, and on window resize oversized width/height shrink and the position clamps (nothing persists while the window is below the minimum, so a transiently flattened geometry never enters storage). - Approach: Panel geometry has no existing slot to carry it, so it is reached through DOM lookup plus inline styles; persistence follows the existing `dsh.conversation.contentWidth` localStorage precedent; unmount restores the CSS defaults. ### Module control mechanism - Description: Each feature module's mount/unmount is controlled centrally by the client index from the configuration toggle state; when disabled, the index unmounts the whole registration and the module itself is unaware of the toggle. - Files: [src/client/index.ts](../../src/client/index.ts) - Solution: The `modules` registry is strongly constrained by `Record`; the index subscribes to the configuration form's snapshot, calls the install function when enabled, and calls the returned disposer when disabled; it touches registrations only when a toggle **actually flips**, because the snapshot also carries status/revision fields and notifies on every settled write — remounting on every notification would remount all the other modules whenever one toggle is written. - Approach: Submodules only register themselves; control logic lives in the index, so adding a module is one entry in `modules`.