# Architecture How `dsh-deepseek-sidebar` gets a DeepSeek web chat into the right sidebar, and why every part of it is shaped the way it is. Line numbers refer to the DSH client bundles shipped in `app.asar` (`ui-sidebar.js`, `ui-layout.js`, `ui-sidebar-right.js`) and to `lib/main.js` of the desktop app; they are evidence for the reasoning, not an API contract. ## 1. Goal and constraints - One row in the **left** sidebar panel list; clicking it must land the chat in the **right** pane, not replace the conversation. - Fullscreen must stay available as a second way in. - The webview must run on the persistent sidebar partition so the sign-in survives restarts. - Everything has to be done from a plugin: no patched client bundle. Only the last constraint really bites, because the row click is hard-coded in DSH. ## 2. Sidebar seats (ui-sidebar.js) `SidebarRoot` renders, top to bottom: | Region | Slot | Kind | | --- | --- | --- | | logo row | `sidebar.brand.mark` / `sidebar.brand.name` | single | | new-session button | — (hard-coded) | — | | panel glyph list | **`sidebar.panellist`** | **list** | | workspaces / sessions | `sidebar.workspaces` | single | | footer buttons | `sidebar.footer.action` | list | | settings gear | `sidebar.settings` | single | Slot kinds are declared around `ui-sidebar.js:487-521`: `brand.mark`, `brand.name`, `toggle.badge`, `workspaces`, `settings` are single-seat, `panellist` and `footer.action` are lists. Only list seats can be appended to without fighting the built-in entry, so the row has to live in `sidebar.panellist`. Each entry becomes a `PanelRow` (`ui-sidebar.js:173-200`), which renders `renderSlot("sidebar.panellist", { size: wide ? 16 : 18, active }, { only: id })` inside `span.panelGlyph` and hard-codes the click: ```js onClick: () => { selectPanel(id); } ``` The icon sized 16 (wide) / 18 (collapsed rail) is what a row component receives — worth knowing when drawing a glyph, since a 24-unit viewBox drawn edge-to-edge at 18px reads visually larger than the neighbouring rows. ## 3. What a row click does to the layout > `ui-sidebar.js:478` — `selectPanel(id)` comes from the sidebar runtime and forwards to > `ctx.layout.selectPanel(id)`. `layout` is a `LayoutController` instance (`ui-layout.js:452`) provided as a service (`ctx.reflect.provide("layout", layout)`, `ui-layout.js:622`). Its `selectPanel` (`ui-layout.js:466-472`): ```js selectPanel(panelId) { if (panelId !== null && !this.hasMainPanel(panelId)) throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`); this.activePanelId = panelId; // ... and notifies subscribers } ``` with `hasMainPanel = (id) => ctx.slots.entries("main").some((e) => e.options.key === id)` (`ui-layout.js:617`). The main seat then renders `renderSlot("main", {}, { entryKey: activePanelId ?? "conversation" })` (`ui-layout.js:119`), i.e. **selecting a panel unmounts the conversation**. ## 4. The right pane is mounted under the conversation - `openTab(kind, options)` — `ui-sidebar-right.js:2128` → `placeTab` (`:2183`): `const definition = this.tabs.get(kind); if (definition === undefined) throw new Error(...)`; the tab address is `pageAddress(kind)` unless `definition.multiple === true`. - `place()` (`:2194`) only opens content and navigates — **it never expands the column**. - `isExpanded()` (`:2246`) reads `this.mountedSurface()?.layout.expanded ?? false`; `toggleExpanded()` (`:2250`) goes through `host.openWithFocus(sessionId, ...)`. - Both need a mounted surface: `require()` (`:2479`) is `const sessionId = this.mounted.getSnapshot(); const actions = sessionId === void 0 ? void 0 : this.actionsFor(sessionId); if (sessionId === void 0 || actions === void 0) throw new Error("sidebarRight: no session surface is mounted")`, and `actionsFor` (`:2476`) reads `this.adopted.get(sessionId)?.store.actions`. - Adoption only happens inside `store.create(scopeKey)` (`adopt`, `:4868-4885`). `RightbarSeat` is registered into slot `rightbar` (`:4913-4924`) and `ExpandButton` into `conversation.session.header.corner` (`:4962`) — **both live under the conversation session**. - `SidebarRightTabRegistry.register` (`:4465`) rejects duplicates: `sidebarRight: tab type id "X" is already registered` and `sidebarRight: tab kind "X" is already registered ()`; the service is provided as `sidebarRightTabs` (`:4848`). A pane body slot must use the **tab type id** as its entry key (`TabSlot`, `:1253-1299`). ## 5. Why the forwarder design fails (v4, reproduced) The natural design: register a panel row `deepseek-chat` *and* a `main` entry with the same key whose `useEffect` opens the tab and calls `selectPanel(null)`. It satisfies `hasMainPanel` and looks like it would work in the right order. It cannot. Clicking the row calls `selectPanel("deepseek-chat")` first: the conversation unmounts, which tears down the session-scoped `rightbar` seat and the `conversation.session.header.corner` button. By the time the entry is mounted and its effect runs, there is no adopted store, so `openTab` throws `sidebarRight: no session surface is mounted`. Diagnostics from a live run (42 entries) showed exactly that, plus `expand:dom-button {found:false}` — the expand button does not even exist at that moment. Retrying, expanding first, or calling `layout.openRightbar` cannot help: the surface is gone, not hidden. ## 6. The shadow method (v5) Since the click handler cannot be changed, change what it calls. `layout` is a plain object instance, so an own property shadows the prototype method: ```js const original = layout.selectPanel; const shadow = function (panelId) { if (panelId === PANEL_ID) { void toSidebar(); return; } // intercept, do not switch return original.apply(this, arguments); // everything else passes through }; layout.selectPanel = shadow; ``` Properties of this approach: - The main area is never switched, so the conversation — and with it the right-pane surface — stays mounted. `openTab` then has a surface to talk to. - `hasMainPanel` is never consulted for our id, so no `main` entry is needed for the row (only the fullscreen key `deepseek-chat-full` is registered in `main`). - Foreign ids still behave exactly as before, including `null` (back to the conversation). - Instance assignment is verified by reading it back; if the property is non-writable the code falls back to `Object.defineProperty` with the same getter-free wrapper, and reports `patch:layout` either way. `toSidebar()` order matters: `selectPanel(null)` (idempotent, clears the active panel) → `openTab(kind, { revealIfOpened: true })` retried every 120 ms (up to 24 times, the 4th attempt first expands the column) until a surface exists → `isExpanded()` false → `toggleExpanded()`. `openTab` itself never expands, and an unexpanded column is invisible. ## 7. Webview lease and partition A guest must be created through `window.dshDesktop.browser.acquire(workspaceKey)`: ```js const lease = await bridge.acquire(SESSION_KEY); // SESSION_KEY = "zcode-browser-use" webview.name = lease; webview.partition = reservation.partition; webview.src = "about:blank#" + lease; // on dom-ready: webview.loadURL(HOME_URL) ``` The main process (`DesktopBrowserGuests.acquire`) derives the partition from the workspace key and `will-attach-webview` rejects any guest whose `params.partition` differs from the issued lease. The release path is `element.remove()` + `bridge.release(lease)`. Upstream builds compute the partition as ```js partition = `dsh-sidebar-browser-${randomUUID()}` ``` — a **memory-only** session, which is why sign-in disappears on every restart. The helper script rewrites that statement to ```js partition="persist:"+workspace.replace(/\W/g,""); ``` which is 50 bytes, exactly the length of the original, so the patch is offset-safe and integrity-check safe (the desktop build does not enforce per-entry integrity). With `SESSION_KEY = "zcode-browser-use"` the partition becomes `persist:zcodebrowseruse`, the same cookie jar as the built-in sidebar browser. ## 8. Diagnostics channel The shell runs on `dsh-app://app` with no remote debugging port, so the renderer console is unreachable. The host half registers a same-origin route: | Method | Path | Purpose | | --- | --- | --- | | GET | `/deepseek-sidebar/report` | return collected entries (`?clear=1` resets) | | POST | `/deepseek-sidebar/report` | accept `{ source, entries }` batches | A relative `fetch("/deepseek-sidebar/report")` from the renderer is forwarded by the shell (`forwardWebRequest`, which only admits `origin: dsh-app://app`) and arrives with the authentication cookie, so `http://127.0.0.1:/deepseek-sidebar/report` can be read from outside while the app runs. Entries are mirrored to `%TEMP%/dsh-deepseek-sidebar/diag.log` (`DSH_DEEPSEEK_SIDEBAR_LOG` overrides). ## 9. Compatibility notes What would break this plugin on a future DSH build, in order of likelihood: 1. The row click stops calling `layout.selectPanel` (nothing to shadow). 2. `selectPanel` becomes a non-configurable own property, or `layout` stops being an instance. 3. Seat names change (`sidebar.panellist`, `sidebarRightTabs`, `sidebar.right.pane.tab`). 4. The tab registry starts requiring a band/multiple declaration for foreign kinds. 5. The partition statement changes shape — the helper script refuses to patch when the original 50 bytes are not found exactly once, and says so instead of guessing.