# dsh-split-screen — structure, runtime failure analysis, alternatives, and the 1.6.0 hardening **Date:** 2026-10-19 · **Author:** dsh-split-screen team **Subject:** `` (hand-made DSH Web UI split-screen plugin), installed in the user's `desktop` profile. **Host under test:** `@deepseek-ai/dsh-desktop` / client packages **0.2.1-rc.2** (Electron 44.7.0, release commit `332895e`) — the exact client the live app runs; global CLI `dsh` is still **0.2.0-rc.2**. **Outcome:** hardened **1.6.0** (verified on an isolated, real 0.2.1 desktop host) + a clear, evidence-backed answer on replacements (none is mature enough today). **Final verified artifact:** `lib/client.js` 7,935 lines / 414,427 bytes, SHA256 `8306116804f96c358b22a17dea01f44c353bac3da111d2d358a52ecac0d95ba8`; `package.json` 1.6.0; tests 16 + 117 checks green. Independent verifier sign-off: 99/99 acceptance checks on an earlier build, then on the shipped build the extended live round — streaming+Stop 10/10, in-pane ask/approval card 14/14 (after the chain-inject-face defect was found and fixed), 0.2.0 live compatibility 9/9, acceptance re-confirm all pass — documented in `docs/VERIFICATION-2026-10-19.md` §14. --- ## 1. Executive summary 1. **Why it kept breaking:** the plugin re-hosts the *native* DSH conversation UI (ChatView, session header, InputBar/Lexical composer, dock rows) inside panes in the **same React tree**. That architecture caused the historical freeze/OOM class (v1.5.0, fixed symptom-by-symptom in 1.5.1) and it makes **every DSH client upgrade a silent-breakage channel**. 2. **What is happening on the user's current app (0.2.1):** the plugin **loads cleanly but never activates** — a silent no-op. 0.2.1 added a static `shell.bottom` row to the AppFrame; the plugin's column measurement misread it as the details column, computed a negative conversation width, and fail-closed to "inactive" with a single console warning. A persisted ≥2-pane layout in that state also **swallowed Alt+T / Alt+Shift+Arrow** gestures. No freeze/OOM/React-storm was reproducible on 0.2.1 — that class is latent, not active. 3. **Behind that gate, two more 0.2.1 contract breaks were waiting:** the 0.2.1 renderer added a `renderFactorySlot` dispatcher (every user bubble calls it) and moves model labels to `renderSlot`; the plugin supplied neither, so panes degraded to fallbacks. Ask/approval cards had *never* worked on 0.2.x because they read a `uiSession.pendingInteractions` property that does not exist. 4. **1.6.0 fixes all of the above and adds a safety layer** so the plugin can never again take the harness down silently: structural geometry selection, 0.2.1 renderer parity, correct pending-interaction source, crash-loop guard/safe mode with a visible chip, per-pane error isolation + retry, validated/capped persistence, a kill switch, a diagnostics API, and full teardown on *every* pane-removal path. 5. **Replacements:** no mature drop-in exists. The catalog's `dsh-split-panes` is not on npm, unmaintained since 2026-09-11, and has open issues proving it crashes on 0.2.x. The best-shaped alternative, **`dsh-split-view`** (iframe-per-pane, MIT, actively maintained), was spiked on 0.2.1: it **loads and splits correctly with no page errors**, but its programmatic session seeding throws `RangeError: Maximum call stack size exceeded` in `ClientSessions.retain` — new panes start blank and reloads lose their sessions. Not a replacement today; a strong "fix-forward" base if desired. Official DSH has no panes/multi-window feature. 6. **Recommendation:** keep the verified **1.6.0** as the daily driver (the structural remaining risk is the borrowed Lexical composer; it is now contained and recoverable), and treat a future architecture change (plugin-owned composer or iframe panes) as a separate, deliberate project. --- ## 2. How the project is structured | Path | Role | |---|---| | `lib/index.js` (4 lines) | Host loader entry. **Provides no host behavior** — the host half is intentionally empty. | | `lib/client.js` (was 6,236 lines / 313 KB → now ~7.9k lines / 409 KB) | The whole implementation: a single browser bundle registered through `window.__ModuleLoader__.load({id, factory(require)})`. No `src/`, no TypeScript, no build step, no JSX (React via `h = React.createElement`). Contains: split-tree store + persistence, DOM/session list sources, 0.2.x session bindings, slot capture + re-host kit, native pane components (transcript/header/composer/TUI line), fallbacks, pane frame/drag/dividers, focus/chords, activation gate, `apply()`. | | `cordis.patch.yml` (5 lines) | Bundle patch inserting the plugin row (`id: split-screen`). | | `package.json` | npm manifest + `dsh.bundle.patch` + `dsh.client` declaration (`platform: web`, inject list). | | `tests/smoke.mjs` | Sandboxed bundle-eval/apply/teardown/activation-gate smoke test (16 checks). | | `tests/safety.mjs` (new in 1.6.0) | 98 checks: 0.2.0/0.2.1 frame fixtures, persistence corruption/dupe/cap, kill switch, crash-loop→safe-mode→retry→reset, health API, B3 chords, retain-retry driver, teardown hygiene. | | `docs/` | `REVIEW-2026-10-03.md` (history), and this round's reports (audit, diagnosis, research, spike, verification, this file). | | `CHANGELOG.md`, `README.md` | Release notes and user documentation (README now documents safety/recovery/diagnostics and corrects false claims). | | Installed copy | `\profiles\desktop\node_modules\dsh-split-screen` — a **copy** of the repo at install time (client.js was 1.5.1, package.json still said 1.5.0), mounted from the `desktop` profile's bundle list. | Design intent (unchanged): split-only activation (≥2 panes), native-looking panes, drag-to-split, keyboard focus, persistence. The 1.6.0 release keeps that intent. --- ## 3. What was actually wrong (evidence-based) Full detail: `docs/DIAGNOSIS-2026-10-19.md` (live, real 0.2.1 desktop host run headless on an isolated DSH_HOME) and `docs/AUDIT-2026-10-19.md` (static, contract-level). ### 3.1 Live findings on 0.2.1-rc.2 | # | Finding | Severity | Evidence | |---|---|---|---| | **B1** | **Geometry regression → split view never activates.** 0.2.1 AppFrame children: `sidebarCol(280) \| centerCol(1320) \| rightbarCol(0) \| bottomRow(1600×0, data-shell-bottom) \| overlayLayer(abs) \| handle(abs)`. `frameColumns()` skipped only `[data-shell-overlay]`+absolute/fixed, so `bottomRow` was taken as the details column ⇒ `right=1600` ⇒ width `−280` < 360 ⇒ `ok:false` ⇒ workspace inert (one warning). Proven on the real desktop host and the 0.2.1 CLI mirror; the 0.2.0 control measures `ok:true, width 1320`. | Critical (feature dead, silent) | `evidence\desktophost\frame-desktophost.json`, `evidence\021\frame021.json`, `evidence\frame020.json` | | **B2** | **0.2.1 renderer-dispatch drift in panes.** 0.2.1's `standardKit` attaches `renderFactorySlot` to every entry; stock `UserStyleBubble` calls `renderFactorySlot("conversation.chat.user.content", …)` and `ModelSelect`'s label calls `renderSlot("conversation.input.model.label", …)`. The plugin supplied neither on all paths ⇒ `TypeError: renderFactorySlot is not a function` / `renderSlot is not a function` in every pane; `PaneBoundary` caught them, panes fell back (missing user bubbles/model label). 0.2.0 control: zero such errors. | High (degraded panes; console spam) | `result-seed2/3/interactions/reloads.json`; `evidence\desktophost\seed2-shot.png` | | **B3** | **"Invisible active".** `isActive` was leaf-count only, independent of geometry. With a persisted ≥2-pane tree (the live profile has one) and failing B1, Alt+T / Alt+Shift+Arrow were `preventDefault()`+`stopPropagation()` with nothing on screen; drag/swap code ran against a nonexistent workspace. | Medium (keys eaten) | `evidence\desktophost\invisible-active.json` | | **B4** | **Ask/approval cards dead on 0.2.x.** Pending interactions were read from `uiSessionService.pendingInteractions`, which does not exist on 0.2.0/0.2.1; the real source is the root `useSessionStatus((snap)=>snap.get(sessionId)?.pendingInteraction)`. README's headline claim was false in panes. | Medium (silent feature loss) | audit §2.3, 0.2.1 `ui-conversation` source | | **B5** | **No safety net.** No kill switch, no crash-loop guard, no reset, no diagnostics; persistence was an unvalidated capability grant (no dedupe/cap/validation); some pane-removal paths bypassed teardown (leaked retained sessions + editor guards + 5 document listeners per sidebar click); conditional hooks in `NativeViewArea` could crash the whole workspace; root-standard harvest mutated state during render. | High (leak/OOM/boot-loop classes) | audit §4/§7, all fixed in 1.6.0 | ### 3.2 What did NOT reproduce on 0.2.1 Stress matrix on the real 0.2.1 desktop host (cold, 2-pane, nested 3-pane, resize sweep 1600→500→1600, Alt+T, pane close, reload loop ×6): **no freeze, no OOM, no React error #185, no client-store subscriber storm, no unresponsive renderer**. Heap flat (perf.memory 87.5→98.2 MB; 3-pane 124.9 MB; CDP after reloads 64.7–92.2 MB non-monotonic), event-loop ping 0 ms every sample, long tasks boot-only (max 272 ms). The v1.5.0-era crash class is fixed in 1.5.1 and latent, not reachable on 0.2.1. **Historical crash evidence** (user's machine): Electron crash log 2026-10-05 — renderer **"exited: oom"** immediately after two `ResizeObserver loop completed with undelivered notifications` lines on 0.2.0 (the v1.5.0 harvest/update-storm class); host exit-143 log on 2026-10-19 11:54 is a SIGTERM (app quit/restart), not a plugin crash. ### 3.3 Root architectural risk (unchanged by 1.6.0) The plugin borrows the **single per-session Lexical editor** from the stock composer while the stock composer for the same session stays mounted under the overlay. The guard/breaker/fail-visible hand-off reduces the probability but cannot make it deterministic (the upstream contract is explicitly single-writer). This is audit finding **M5**. Removing it requires either a **plugin-owned composer** wired to the public input actions, or **iframe isolation** per pane. 1.6.0 contains the failure with per-pane boundaries, a retry UI, an error budget and safe mode; it does not claim to eliminate M5. --- ## 4. What 1.6.0 changes ### 4.1 0.2.1 compatibility - **Structural geometry**: columns are selected by structure, not index — `[data-shell-overlay]`, `[data-shell-bottom]`, `[data-shell-leading]`, `[data-side]`, absolute/fixed and full-width short rows are excluded; the centre column is located from the live conversation scroller; vertical insets come from the centre column (so a populated `shell.bottom` is never covered); a validated `grid-template-columns` fallback; fail-closed preserved. Tested with pure 0.2.0/0.2.1 frame fixtures and on the live 0.2.1 host. - **Renderer parity**: a pane-local `renderFactorySlot` outlet built on the public `slotsService.hostFace()` (subscribe/version/factory lookup, `factoryStoreOf`, `retainFactoryOccurrence`, locale/store/inject, ancestry recursion guard, its own fallback boundary), plus declared-children `renderSlot`/`renderSlotChain` on every re-hosted entry. No `… is not a function` pane failures on 0.2.1. - **Pending interactions** now come from the root `useSessionStatus` map (legacy `uiSession` source kept only as 0.1.x fallback) — in-pane ask/approval cards work again. - **ChatView required props** (`loadThrough`, `openExternalLink`) get safe defaults; in-pane link clicks/load-through no longer throw into the pane boundary. - **Entry `inject()` caching** per (entry, session, scope) — the streaming hot path no longer re-runs inject factories per render. ### 4.2 Host safety & recovery - **Kill switch** (`dsh.split-view.enabled.v1 == "0"`, `window.__dshSplitView.setEnabled(false|true)`), persisted, layout kept. - **Crash-loop guard / safe mode**: activation attempts are persisted before they can crash; after ~9 s healthy the counter resets. A boot that never became healthy (or two consecutive unhealed attempts) does **not** auto-activate: the stock view stays untouched, the saved layout is kept, and a small dismissible chip offers **Retry / Disable**. - **Error budget**: ≥3 boundary catches in ~20 s while active → safe mode; a workspace-level crash → safe mode immediately; a single failing pane is isolated by its own boundary with visible retry (other panes keep streaming). - **Validated persistence**: strict id grammar, duplicate de-dupe, `MAX_PANES = 6`, depth 6; invalid trees are discarded + cleared with one console note; repaired trees are re-persisted. `__dshSplitView.reset()` clears everything. - **B3 fix**: hotkeys, drag bridge, sidebar click-to-load and DOM observation are gated on `operationalActive = active && bounds.ok`; a geometry-blocked tree no longer eats keys and is left intact. - **Teardown on every path**: tree mutations schedule departed-leaf disposal; `ChatPane` retains/releases per mount with a per-session refcount; retained session references, editor guards (5 document listeners), typing subs and image intake are released on ✕, sidebar swap, `closeFocused`, `movePane` and plugin teardown. - **Hooks/renders**: `NativeViewArea` hooks are unconditional; root-standard harvest no longer mutates the store during render; a genuine renderer-generation change triggers one controlled pane remount instead of swapping dead faces under live panes. - **Drag/keys**: the inactive drag bridge claims only the plugin's session MIME (conservative known-id fallback); global chords no longer `stopPropagation()`; the document-wide `aria-selected` focus-swap observer is gone (real sidebar-row click only). ### 4.3 User-friendliness & diagnostics - One-time first-split tip card; a **?** button in the pane header opens the help card (shortcuts, gestures, how to disable). - Status chips: safe mode (Retry/Disable/dismiss) and "could not start" (Retry/Disable/help) — plugin chrome only, never over stock controls. - `window.__dshSplitView`: `health()` (plugin version, host release, tested-host flag, enabled/safeMode/active/geometryBlocked/panes/maxPanes/blockedReason/failures/lastError/geometry/persistedTree/crashGuard), `reset()`, `retry()`, `setEnabled()`, `help()`, `setHelpOpen()`, plus the existing `focusChat()`/`closeFocused()` companion bridge. All return plain JSON and never throw. - One console warning per distinct degraded condition, each with the recovery API; EN/ZH strings; reduced-motion respected. --- ## 5. Replacements: what exists and whether to switch Full matrix, sources and install/migration notes: `docs/RESEARCH-2026-10-19.md`. Hands-on result: `docs/SPIKE-2026-10-19.md`. | Candidate | Technique | Verdict | |---|---|---| | **`dsh-split-panes`** (catalog's lead) | in-process native re-hosting, vendored renderer pinned to core 0.1.5-alpha.2 | **No.** Not on npm; no commits since 2026-09-11; open issues #7/#8 document a crash on 0.2.0 (`renderFactorySlot is not a function`). Architectural reference only. | | **`dsh-split-view`** (npm 0.2.7, MIT, 2★) | **iframe per pane** — each pane runs a full stock DSH client pinned to a session; LRU hibernation; max 4 live panes | **Not today, best fix-forward base.** Spike on isolated 0.2.1: loads clean, split UI works (2–3 panes), typed in both composers, opened real history with 0 console errors, heap flat (64 MB/2 panes), no host crash. **But** programmatic session seeding throws `RangeError: Maximum call stack size exceeded` in `ClientSessions.retain` → panes start blank and reloads lose their session (must re-open each). | | **`@ai-eks/dsh-docking-layout`** (npm 0.2.0-rc.2, MIT, ~1.6k dl/mo) | iframe pool + tabs/groups; README claims `^0.2.1-rc.2` | **Unresolved.** Local catalog marks it incompatible (2026-10-08 snapshot) while upstream claims the user's exact build. Not spiked (timebox); next candidate if wanted. | | **`dsh-multi-chat`** (npm 1.0.8) | N separate `dsh web --port` processes in an iframe grid | **Complementary / isolation fallback.** Cannot take down the main window; costs N× RAM; not same-session panes. | | **Official DSH** | no panes/split/multi-window feature (verified live Host service catalog + ~300-package inventory); Agent Teams is the official multi-session UX; the internal DockKit layout kit is explicitly **not a stable API** | **Nothing to adopt.** | | Layout libraries (dockview, react-mosaic, flexlayout, rc-dock, allotment, react-resizable-panels) | layout geometry only | **Would not fix the actual failure class** (session/UI re-hosting). | **Bottom line:** no mature drop-in replacement exists. The iframe technique is the only one that structurally removes the freeze/OOM class, and the best candidate is close but currently broken on 0.2.1's session-retain path; it can be re-spiked in under an hour with the harness left in place. --- ## 6. Recommendation 1. **Keep the hardened plugin (1.6.0) as the daily driver.** It is verified on the user's exact 0.2.1 client and now degrades visibly and recoverably instead of taking the harness down. Its remaining structural risk (borrowed Lexical composer) is contained, not eliminated. 2. **If stability outranks features**, the zero-risk option is to disable split view (`__dshSplitView.setEnabled(false)` or remove the plugin) and use separate DSH windows/processes for parallel agents. 3. **If a different architecture is desired**, two deliberate paths: (a) contribute a 0.2.1 seed-path fix to `dsh-split-view` and adopt iframe isolation (lose our TUI composer/agent-team chrome, 4-live-pane cap, re-open sessions), or (b) rewrite our composer layer as plugin-owned (keeps features; medium effort, removes the last race class). Both are projects, not patches — do not attempt "quick swaps": the harness proved why desk research alone is not enough. 4. **Process**: pin the plugin's tested DSH releases (`TESTED_RELEASES`), re-run the isolated 0.2.1 harness after every DSH upgrade, commit the 1.6.0 work (it was never committed; the installed copy also carried a stale package.json), and record the app/plugin versions together. --- ## 7. Install / verify / rollback The plugin is currently **installed in the `desktop` profile as 1.5.1** (inert on 0.2.1). To move to the verified 1.6.0: ```powershell # from an elevated/ordinary shell; affects the desktop profile only dsh plugin --profile desktop add file: # then fully quit and restart DeepSeek Harness (a page reload is not enough) ``` After restart, in DevTools console: ```js window.__dshSplitView.health() // expect: pluginVersion "1.6.0", hostRelease "0.2.1-rc.2", testedHost true, // geometryBlocked false, and after opening a split: active true / panes 2 ``` Rollback (keeps the saved layout): ```js window.__dshSplitView.setEnabled(false) // instant kill switch // or, profile-level: dsh plugin --profile desktop remove dsh-split-screen ``` --- ## 8. Evidence index | Document | Content | |---|---| | `docs/AUDIT-2026-10-19.md` | Static audit of the 1.5.1 bundle: architecture map, M1–M6 root causes, S1–S9, risk table, test gaps, verdict that patch-hardening removes the freeze/OOM classes but not M5. | | `docs/DIAGNOSIS-2026-10-19.md` | Live isolated 0.2.1 desktop-host diagnosis: B1 geometry, B2 renderer drift, B3 invisible-active, stress/memory matrix, pass/fail table, verifier quick-start. | | `docs/RESEARCH-2026-10-19.md` | Alternatives matrix with GitHub/npm/catalog evidence, maturity assessment, migration sketch. | | `docs/SPIKE-2026-10-19.md` | `dsh-split-view` 0.2.7 on isolated DSH 0.2.1: what worked, exact `ClientSessions.retain` failure, screenshots, recommendation. | | `docs/VERIFICATION-2026-10-19.md` | Independent acceptance runs on the real 0.2.1 desktop host: 99/99 checks on an earlier build, then the extended live round on the shipped `83061168…` artifact — streaming/Stop (10/10), in-pane ask/approval card (14/14, after a found-and-fixed renderer-chain defect), 0.2.0 live compatibility (9/9), acceptance re-confirm, kill-switch/crash-loop/corrupt-tree, 133 sandboxed test assertions. §14 is the release sign-off. | | `docs/ROBUSTNESS-REPORT-2026-10-19.md` | This consolidated report (structure → failure analysis → 1.6.0 changes → alternatives → recommendation → install/rollback → evidence). | | `tests/smoke.mjs`, `tests/safety.mjs` | Reproducible non-live tests (`npm test`). | | `/` | Scratch DSH home, 0.2.1 CLI install, mirror profile, launch/probe scripts and raw evidence (never the live profile). | --- ## 9. Residual risks and honest limitations - **M5 (borrowed Lexical composer)** remains: a visible-but-untouchable/dead-Enter composer is still probabilistically possible; 1.6.0 detects/recoveries it (boundary/safe mode/kill switch) but does not remove the race. - **Full factory-local nested slot rendering** (`useFactorySlot`) degrades to a declared fallback; factory-private-context content can fall back inside its own boundary. Ordinary user-bubble/model-label factory content is covered. - **0.2.1 was verified on a headless real desktop Host**, not the Electron window; window chrome/titlebar absent. The extended round added real streaming/Stop and an in-pane ask/approval card round-trip, but the other installed plugins' (cost-meter/im/result-only-view/hotkeys) full runtime interplay, attachment uploads and long streaming sessions were not exhaustively exercised. - **A renderer-chain defect was found late in release verification** (in-pane ask cards threw `useQuestionCard is not a function` because the manual composer-chain render omitted the elected entry's inject face) and fixed; the fix is covered by a new unit scenario and the extended live check. This is exactly the class of silent feature loss that future DSH upgrades can reintroduce — keep the post-upgrade verification habit. - **1.5.0-era crash logs** prove the freeze/OOM class existed; it did not reproduce on 0.2.1 in 10 runs before or after the fix. The safety layer is defense-in-depth, not proof of impossibility. - **Desktop-profile install/restart was intentionally not performed** by the team (the user's live app was never contacted); the install step above is the user's call. --- *Reports in this round were produced by four independent team workstreams (static audit, live diagnosis, alternatives research, replacement spike) plus the implementation and an independent verifier; the Lead consolidated and reviewed the final diff. Tests and evidence are reproducible from the paths above.*