# AUDIT — dsh-split-screen v1.5.1 (current working tree) **Date:** 2026-10-19 **Auditor:** code auditor (Team task-2) **Target:** `\lib\client.js` — 6,236 lines, single-file module-loader bundle (`window.__ModuleLoader__.load`), host half `lib/index.js` is an empty `apply()`, `tests/smoke.mjs` is a 16-assertion shim. The working tree contains uncommitted v1.5.1 changes (`git diff`: +127/−19); **this audit is against the current file**, which is what the user runs. The desktop profile's installed copy (`…\.dsh\profiles\desktop\node_modules\dsh-split-screen\lib\client.js`, 313,427 bytes) is byte-size-identical to the working tree. **Question answered:** *why does this plugin still break the host, and can it be made robust?* --- ## 0. TL;DR 1. **The live host is DSH `0.2.1-rc.2`, not `0.2.0-rc.2`.** The app bundle at `\resources\app.asar` is `@deepseek-ai/dsh-desktop 0.2.1-rc.2` (app.asar replaced on 2026-10-17). The plugin was only ever verified against 0.2.0 (0.1.5 in the code comments). I read the 0.2.1 client packages from the asar-extracted tree (`\core-0.2.1\…`). 2. **Version drift is NOT a load-time blocker.** The three "removed" packages (`dsh-client-store`, `dsh-client-ui-slots`, `dsh-client-ui-primitives`) are still supplied as **platform seed modules** inside the 0.2.1 web frontend (`staticModules` in `dsh-web-frontend/dist/assets/index-CN7NIWNw.js`, function `SZ()`), and the module loader resolves seed words before anything else (`dsh-client-modules/lib/client.js:833-841`). The plugin's `require("@deepseek-ai/dsh-client-ui-primitives")` and its `dsh.client.inject` list therefore still work; an unknown inject package is silently skipped, not fatal (`client.js:789-792`). 3. **One contract is genuinely wrong on 0.2.x (and was already wrong on 0.2.0):** pending interactions. The plugin reads `uiSession.pendingInteractions` (`client.js:1745-1769`); that property does not exist on the 0.2.x `uiSession` service. The real surface is the root hook `useSessionStatus(...).get(sessionId)?.pendingInteraction` (`dsh-client-ui-conversation 0.2.1:17747`). Consequence: **ask_user_question / approval / plan-review takeover cards never render inside panes on the desktop app**, despite README's headline claim. 4. **The continuing breakage is architecture-intrinsic, not (mainly) a 0.2.1 regression.** The plugin re-hosts the native React conversation inside panes and fights the stock composer for the **same per-session Lexical editor**. That single-writer contest is *identical* in 0.2.1 (`ComposerContentEditable` calls `editor.setRootElement(el)` on mount, `setRootElement(null)` on unmount — `ui-conversation 0.2.1:18083-18104`). Everything else (render storms, leaky leaf lifecycle, unvalidated persistence, conditional hooks, DOM scraping) amplifies that contention into freezes, dead composers and OOM. 5. Verdict: **single-file patch-hardening can reduce frequency but cannot reach acceptable robustness for the composer path.** The must-fix set below is real and bounded; the editor-root contention needs a structural change (drop native composer re-hosting, or iframe isolation) before the "this plugin breaks my harness" complaint can go away. Residual risk after all listed fixes: **moderate-high** for a re-hosting design, **low-moderate** for a panes-as-native-composer design. --- ## 1. Scope, method, evidence, limits - **Static read** of the whole current `client.js` (`git diff` for 1.5.1 included), `README.md`, `CHANGELOG.md`, `docs/REVIEW-2026-10-03.md`, `\backup\FINDINGS.md`, `geometry-fix.patch`, `tests/smoke.mjs`, live profile config (`\profiles\desktop\package.json`, `cordis.patch.yml`). - **Contract check** against the live 0.2.1 packages extracted from the asar (`\core-0.2.1\dsh\node_modules\@deepseek-ai\*`) and the 0.2.0 packages installed with the global CLI (`\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai\*`). Evidence paths for 0.2.1 are relative to `…/core-0.2.1/dsh/node_modules/@deepseek-ai/`. - **Read-only**: no edits outside this document, no plugin commands, no profile changes, no live-app interaction. `node --check` not needed (bundle loads; v1.5.1 was syntax-checked by its author). - **Limits:** I did not run the live renderer (diagnostician owns live reproduction; `docs/DIAGNOSIS-2026-10-19.md` was not present when this was written). Runtime claims about the user's actual crash are therefore *mechanism* claims, not reproductions. `dsh-api-session-controller` was not present in the extracted 0.2.1 tree, so `sessions.retain()/scope()/binding()` signatures are **verified only by usage** in 0.2.1 `ui-session` (`ui-session/lib/client.js:841,915-978`) and by the 0.2.0 typings — flagged UNVERIFIED where it matters. - Lead-supplied evidence cited here: app.asar replaced 2026-10-17 17:20, installed 10-18 08:54; Electron renderer crash log 2026-10-05 "Desktop renderer exited: oom" preceded by two "ResizeObserver loop completed with undelivered notifications"; host exit 143 (SIGTERM) log at 2026-10-19T03:54:43Z before the 11:55 local restart; persisted localStorage trees with 2–3 panes from 10-13…10-19, final persisted value a single leaf. --- ## 2. Version drift: 0.2.0-rc.2 → 0.2.1-rc.2 (live host) ### 2.1 Environment ground truth | Fact | Value | Evidence | |---|---|---| | Live desktop app | `@deepseek-ai/dsh-desktop 0.2.1-rc.2` (Electron 44.7.0) | `app.asar/package.json` (read tool) | | Live runtime packages | all `@deepseek-ai/dsh-client-*` `0.2.1-rc.2` | extracted tree package.jsons | | Plugin verification baseline | 0.2.0-rc.2 (comments mention 0.1.5 as well) | `docs/REVIEW-2026-10-03.md`, `FINDINGS.md`, code comments | | Plugin version in tree / live profile | 1.5.1 (tree) / package 1.5.0 in profile but client.js identical to tree | profile `node_modules/dsh-split-screen` | | Package-surface diff (Lead) | removed: `dsh-client-store`, `dsh-client-ui-primitives`, `dsh-client-ui-slots`; added: `dsh-client-ui-deepseek-model-label-1017`, `dsh-client-ui-plugin-market`, `dsh-client-ui-secret-mask` | Lead task-1 diff, confirmed against extracted tree | ### 2.2 Correcting the "removed inject package" hypothesis (inspected and cleared) The user-visible chain would be severe if a removed inject package aborted boot. It does not: - **Seed-word table (0.2.1):** `dsh-web-frontend/dist/assets/index-CN7NIWNw.js` builds the loader's `staticModules` with `function SZ(){return{react:sC,"react/jsx-runtime":uC,"react-dom":mC,"react-dom/client":yC,"@deepseek-ai/cordis":Fb,zod:Zj,"@tanstack/react-virtual":Yj,"@deepseek-ai/dsh-client-store":wI,"@deepseek-ai/dsh-client-ui-slots":SI,"@deepseek-ai/dsh-client-ui-primitives":zU,"@deepseek-ai/dsh-client-ui-dockkit":xZ}}`. `dsh-client-store`, `dsh-client-ui-slots` and `dsh-client-ui-primitives` are **still provided**, just no longer as standalone npm packages — they are merged into the frontend bundle. - **Loader semantics:** `dsh-client-modules/lib/client.js:833-841` — `require(spec)` returns `this.seed.get(spec)` first; only then looks for a materialized module/factory. So `client.js:69` `const P = require("@deepseek-ai/dsh-client-ui-primitives") || {};` resolves on 0.2.1. - **`dsh.client.inject` semantics:** `client.js:776-794` — for each injected package name it does `const dependency = this.graphRows.get(packageName); if (dependency !== void 0) await this.arriveDependency(...)`. A package that is not a separate graph row is **silently ignored**; the host composes the graph from real packages only (`dsh-client-modules/lib/index.js:401-410` carries `inject` as row metadata). Therefore `package.json`'s `inject: [dsh-client-ui-layout, dsh-client-locale, dsh-client-ui-primitives]` is harmless on 0.2.1. - **`slots` service still exists:** 0.2.1 `dsh-client-ui-renderer/lib/client.js:1347-1348` `super(ctx, "slots")`; `inject()` 1368, `provideRoot()` 1465, `installScope()` 1491, `entries()` 1542, `entriesOfSlot()` 1554, `spec()` 1582, `subscribe()` 1591, `_register(options, component)` 1603. The plugin's calls (`entries`, `entriesOfSlot`, `spec`, `register`, `inject`, `provideRoot`) all map to live API. - **`shell.overlay` unchanged:** `dsh-client-ui-layout/lib/client.js:643-646` `"shell.overlay": { kind: "list", scope: "root" }`; rendered at 313 `renderSlot("shell.overlay", {})`; overlay layer still tagged `"data-shell-overlay": true` (351). The plugin's registration/inject and its `document.querySelector("[data-shell-overlay]")` geometry probe still land. - **Root-standard hand-off unchanged (and the plugin's seat filter matches):** 0.2.1 `dsh-client-ui-renderer` `standardKit()` (`client.js:736-767`) builds `kit = { ...standard, renderFactorySlot }`, then only adds `t` (locale), `useStore`/`actions` (store), `renderSlot`/`renderSlotChain` (children), `SessionProvider` (session-scoped children). The plugin filters exactly that set in `FRAMEWORK_SEATS` (`client.js:1068`) and its `shell.overlay` entry declares no children → the harvested object in `rootStandard.value` is precisely the root standard faces (`useSessions`, `useSessionStatus`, `useWorkspaces`, `usePanelInfo`, `useResource`, `useSessionRetainInfo`). **The grow-only harvest is therefore structurally correct on 0.2.1.** **Bottom line:** the removed-package theory is a red herring. Do not tell the user (or the implementer) that the plugin's inject list broke the client boot. The 0.2.1 loader is fully backward compatible for the surfaces this plugin touches. ### 2.3 Contracts that DO drift / are wrong on the live host | # | Contract | Plugin assumption | 0.2.1 reality | Impact | |---|---|---|---|---| | D1 | **Pending interactions** | `uiSessionService.pendingInteractions` exists and is a Map store (`client.js:1745-1769`); chain owner prop `pendingInteraction` (`4340`, `4274-4278`) | No such property in 0.2.1 (or 0.2.0!) `ui-session` service. Root contribution is `provideRoot({hooks:{sessions, sessionStatus}, keyedHooks:{sessionRetainInfo, sessionAccess}})` (`ui-session 0.2.1:1104-1113`). Stock uses `useSessionStatus((snap)=>snap.get(sessionId)?.pendingInteraction)` (`ui-conversation 0.2.1:17747`) and passes `pendingInteraction` in the chain owner (`17846-17849`). | **Feature silently dead** in panes: question / approval / plan-review cards never take over the pane composer. README claim is false. Severity: should-fix (user-facing), plus a false API premise that will bite again. | | D2 | **Composer editor root ownership** | One shell-owned Lexical editor per session; pane's host div must hold the root; guard reclaims it from the hidden stock composer (`client.js:2749-3005`) | Same single-writer model, still one editor per session: `ComposerContentEditable` calls `editor.setRootElement(el)` on mount / `setRootElement(null)` on unmount (`ui-conversation 0.2.1:18083-18104`; 0.2.0:16552-…); `SessionInputShell.get editor()` still exists (`14907-14910`). | **Unchanged architectural contention.** No drift, but no fix either: whichever composer mounts last owns the editor; the plugin's reactive re-point is a race, not an ownership protocol. | | D3 | **ChatView props** | Passes kit + extra props; supplies `renderSlot`, `loadOlder`, `chatScroll`, `fileMentions`, `inspectCall`, `forkAt`, `openFile`, `loadImage`, `inspect`, `onInspectDone` (`client.js:3134-3175`) | 0.2.1 signature (`ui-chat/client.js:2744`): `{useSession,useChat,useConversation,useSessions,renderSlot,sessionId,openFile,openExternalLink,loadOlder,loadThrough,loadImage,inspectCall,chatScroll,forkAt,fileMentions,usePresentation,useProjection,t}`. `useChatNode/useChatNodeProcess/useChatGroup/useStore/actions/openSkill` were removed in 0.2.1 — the plugin's extra props are harmless. **`loadThrough` and `openExternalLink` are required in BOTH versions and the plugin never supplies them** → any in-pane "load through"/external-link path throws into `PaneBoundary` and blanks the transcript until remount. | Should-fix: pass safe stubs (`openExternalLink` → `window.open`/host service, `loadThrough` → `()=>{}`/`session.loadOlder`-equivalent) and log the degradation. | | D4 | **Root-standard re-render behaviour** | v1.5.1 grow-only merge assumes root faces can be re-rendered with an empty kit and that "a later render may add or refresh a face" (`client.js:1099-1124`) | 0.2.1 memoizes root standard per binding: `rootStandardCache.get(rootBinding)` + `materializeStandardBinding` (`ui-renderer 0.2.1:667-702`), and root standard sources are installed by stable service objects. A genuinely *empty then full* render of the same root binding is not a normal path. | The v1.5.1 fix treats a symptom whose source is probably a **second renderer/shell generation** (remount / HMR / desktop re-composition), not ordinary root props churn. The grow-only merge keeps panes alive through a remount, but leaves their **session/root references pointing at the dead generation** (see N5). Severity: must-fix (see M4). | | D5 | **`sessions` service shape** | `sessions.retain(id,{source})`, `sessions.binding(id).session`, `sessions.scope(id).get("conversation")`, `sessions.list` as a store (0.1.x only) (`client.js:1413-1425`, `1643-1652`, `4121-4130`, `6102-6109`) | 0.2.1 `ui-session` uses `this.sessions.binding()`, `.retainInfo()`, `.scope()`, `.list.getSnapshot()`, `.sessionOf()` (`ui-session 0.2.1:841,915-978`). The plugin's used subset matches, **except** the first `retain` argument/options signature is not verifiable from the extracted tree (controller package absent) — UNVERIFIED but the plugin already tolerates `undefined`/throws (falls back to DOM source, then to own renderers). | Monitor; contract test required before trusting 0.2.2+. | | D6 | **DOM markers the plugin scrapes** | `[data-shell-overlay]`, `[data-row-key^="session:"]`, `aria-selected`, `[data-composer-card]`, `[data-composer-input]`, `[data-conversation-scroll]`, `data-slot` stamps | All still present in 0.2.1: `data-shell-overlay` (ui-layout:351), session rows `"data-row-key": \`session:${node.id}\`` (ui-workspace 0.2.1:2275), drag payload `setData("text/plain", node.id)` (2288), composer markers (`ui-conversation:18102` `"data-composer-input": true`, card markers 5 files), `data-conversation-scroll` (2 files), `data-slot`/`data-slot-id` outlet stamps (renderer). | No drift found. But these are unversioned DOM internals; any future shell CSS/markup change breaks the plugin silently (fail-closed for geometry, no fail-closed for the rest). | | D7 | **Scope machinery** | Plugin materializes session faces itself from `uiSession.bindingSource(reference)` and hands them as props; bypasses the React scope context (`client.js:1437-1464`, `2222-2251`) | 0.2.1 moved scope materialization behind React context: `ScopeBindingContext` (`ui-renderer 0.2.1:182-209`), `standardProps(scope, rootBinding, scopeBinding)` throws without a scope binding (`680-702`), `installScope("session", adapter)` (`1484-1502`). Stock entries rendered inside panes get props-based faces (works), but any re-hosted component that internally calls stock `renderSlot` will get the plugin's emulation, not the stock outlet — plugin's dispatchers must therefore keep emulating: `list` kind renders all, `entryKey`, `hookContext` factories, `data-slot(-id)` stamps. v1.5.0/v1.5.1 patched three such gaps; this is a **permanent emulation tax** and a top drift-risk area going forward. | Should-fix / accept-with-tests: add contract tests against the extracted 0.2.1 renderer semantics (`FRAMEWORK_SEATS`, list/chain/keyed dispatch, `hookContext`) so the next shell change fails in CI, not in the user's renderer. | ### 2.4 What this means for the user-visible complaint The 2026-10-19 complaint coincides with the app upgrade (app.asar 10-17, install 10-18) but **nothing in the 0.2.0→0.2.1 diff examined here explains a host-breaking regression by itself**. The user's persisted 2–3-pane trees kept re-activating the workspace at every boot, which exercises the fragile paths in §4 on a shell generation the plugin was never tested against. The stable features the user notices as "bugs" are intrinsic: composer dead/untouchable (editor-root contest D2/M5), freezes/storms (M2/M4/M6/S4), panes vanishing (S1), split view never appearing (S6) or activating over stale panes (M3). --- ## 3. Architecture map of `lib/client.js` (current file) Top-level: one factory `(require) => module.exports` inside `window.__ModuleLoader__.load({id:"dsh-split-screen"})` (`client.js:54-6236`). 6,236 lines, no source maps, no build. Sections (by line): | Lines | Section | Key symbols | |---|---|---| | 54-149 | entry + i18n | `React = require("react")`, `P = require(dsh-client-ui-primitives)`, `EN`/`ZH`, `t()` | | 165-738 | stylesheet | `DSV_CSS` (`insertStyle` 739), all `.dsv-*` rules; overlay `z-index:60` (167), divider z 3, ghost z 90 fixed (564) | | 786-1020 | **split-tree store + persistence** | `normalizeTree` 866, `createTreeStore` 892, `treeStore` 1022. `isActive = leaves>=2` (933); `TREE_KEY` 76 persisted at every `commit` (910-914); sizes not persisted during drag (1012), `persistNow` 1014 | | 1024-1036 | services bound in `apply()` | `sessionsService`, `sessionsListSource`, `slotsService`, `localeService`, `uiSessionService`, `conversationT` | | 1038-1172 | **root standard harvest** | `rootStandard` 1060, `FRAMEWORK_SEATS` 1068, `extractRootStandard` 1070, `sameFaces` 1082, `offerRootStandard` 1099, `publishRootStandard` 1128, `useRootStandard` 1148 | | 1174-1396 | 0.2.0 session list + current session | `SESSION_ROW_SELECTOR` 1185, `readSidebarSessions` 1203, `createDomSessionListSource` 1239 (observer on `document.body`, 1295), `currentSessionId` 1341, `useDomSelectedSession` 1368 | | 1398-1704 | 0.2.0 session bindings + readiness | `paneSessionReferences` 1410, `ensureSessionReference` 1413, `releaseSessionReference` 1427, `resolveSessionBinding` 1437, `SESSION_BINDING_GRACE_MS` 2500ms 1475, `useSessionBindingReady` 1477, `useSessionFace` 1662 | | 1706-1873 | hooks | `useObserved` 1715, `useTree` 1727, `useSessionPendingInteraction` 1751, `useSessionsList` 1771, `makeUseSession` 1777, `makeUseStoreFace` 1798, `makeUseProjection` 1843 | | 1898-2029 | module-level mutable registries | `captureLog` 1902, `chatScrollMemory` 1992, `paneImageIntake` 1996, **`paneEditorGuards` 2002**, `composingSince` 2017, `lastTypedSessionId` 2042, `paneTypingUnsubs` 2043, `paneChatStores` 2051 | | 2136-2305 | store/inject faces + kits | `entryStoreCache` 2137, `entryStoreInstance` 2150, `injectFaceProps` 2192, `materializeSessionStandard` 2222, `makeKit` 2260, `kitProps` 2299 | | 2307-2625 | **slot capture & re-host kit** | `slotInjectCache` 2326, `slotInjectFaceOf` 2363, `bindSlotHookFactories` 2375, `makeRenderers` 2392 (`renderSlot` 2475, `renderSlotChain` 2526, `renderList` 2553), `PaneBoundary` 2597 | | 2627-3130 | **prepareNative** | kit + renderers 2636-2639; viewer/composer/header inject capture 2642-2666, 3072-3116; composer input actions + image intake 2680-2737; **Lexical root guard** 2749-3005; typing memo 3007-3029; `composer` bundle 3030-3067 | | 3131-3381 | native pane components | `viewExtras` 3134, `NativeTranscript` 3178, `NativeHeader` 3190, `NativeViewArea` 3213, `NativeComposer` 3284 | | 3383-3773 | composer chrome | `ComposerModeIcon` 3384, `visibleComposerRect` 3407, `useComposerAlignedRow` 3431, `DsvInputDock` 3499, `DockLine` 3510, `TuiQueueLine` 3581, `TuiMachineComposer` 3601, `TuiPlainComposer` 3695, `SlimHeader` 3728 | | 3775-4258 | fallback renderers | transcript row components 3779-4003, `FallbackTranscript` 4005, `FallbackComposer` 4113 | | 4260-4467 | **ChatPane** | memo kit 4265-4291, `session.open()` effect 4293-4300, header/view/composer composition 4316-4331, **composer chain election** 4339-4379, TUI branch 4382-4388, last-resort Enter 4403-4428, hero agent-preset 4449-4458 | | 4469-5031 | **PaneFrame** | drop zones/ghost 4492-4515, ResizeObserver 4529-4542, composer-mode store subscription 4548-4553, **card measure observers** 4573-4700, mode-button placement 4704-4749, drop handlers 4767-4834, header drag 4836-4854, **paste capture + image fetch** 4878-4952, render 4856-5010; memo comparator 5017-5031 | | 5033-5200 | canvas/tree render | `childStyle` 5037, `RenderLeaf` 5044 (**leaf close cleanup 5062-5080**), `RenderSplit` 5085, `Divider` 5106, `addCurrentSession` 5162, `Canvas` 5175 | | 5198-5299 | picker | `pickerRequest` 5202, `SessionPicker` 5221 | | 5301-5600 | focus + global keys | `resolveFocusedSessionId` 5309, `focusPaneScroller` 5325, `composerModeStore` 5370, `focusPaneInput` 5400, `focusSiblingPane` 5432, `onPaneNavKeydown` 5476, `onComposerModeKeydown` 5502, `registerGlobalKeys` 5523, `window.__dshSplitView` bridge 5539 | | 5560-5901 | **workspace frame** | `frameColumns` 5587, `measureColumnBounds` 5603, `columnRect` 5652, `WorkspaceBody` 5671, `WorkspaceBoundary` 5684, `WorkspaceApp` 5705 (activation gate 5723, DOM source activation 5728-5733, geometry effect 5771-5834, sidebar-click-to-swap 5841-5851, render gates 5878-5899) | | 5903-6081 | inactive drag bridge | `registerDragBridge` 5984 (document dragover/drop/dragleave/dragend capture 6070-6080) | | 6083-6236 | `apply()` | `appliedOnce` 6090, service binding 6094-6112, locale register 6114-6136, IME tracker 6138-6151, stylesheet 6153, keys 6158, drag bridge 6162, **full teardown disposer 6164-6216**, `slots.inject("shell.overlay",…)` 6220-6224, `exports.inject` 6232 | **Data flow:** `apply(ctx)` resolves services → registers one root-scoped `shell.overlay` entry (`WorkspaceApp`) → `WorkspaceApp` harvests the root standard props the slot renderer hands it, activates only when the persisted/edited tree has ≥2 leaves and the conversation-column geometry measures confidently → `Canvas` renders the recursive split tree → each leaf `RenderLeaf` resolves its session binding (`uiSession.resolve` on 0.1.x / `sessions.retain` + `uiSession.bindingSource` on 0.2.0+) and mounts `PaneFrame` → `ChatPane` builds a per-pane kit (`prepareNative`) by capturing slot entries (`ctx.slots.entriesOfSlot`) from the live ledger, re-composing their props with the kit/injects, and rendering stock `ChatView`/`InputBar`/header/plugin chrome inside `PaneBoundary`; composer geometry is measured from the live DOM and mirrored into CSS vars; the Lexical root guard and the pane image intake make the re-hosted composer behave like the stock one. **Persistence:** localStorage `dsh.split-view.tree.v1` (`TREE_KEY`), `dsh.split-view.open.v2` (defined, only read/written in the 0.1.x branch — currently unused by 0.2.x paths: `OPEN_KEY` 77, `RAIL_KEY` 78 are dead constants), plus `dsh.split-view.last-typed.v1` (2044). Tree written on every commit; corrupt JSON is caught (`897-904`, `910-914`, `1016-1017`). **Activation gate:** `isActive() = leafIds.length >= 2` (`933`) → `WorkspaceApp` render gate (`5723`, `5878-5879`); while inactive no observers/scraping exist. With ≥2 panes, DOM list observer (`5728-5733`), bounds observer (`5771-5834`), sidebar-selection observer (`5841`), editor guards, card observers etc. all become active. **Root standard harvest:** the plugin's own `shell.overlay` entry receives the materialized root standard faces as props (`standardKit`), stores them in module-level `rootStandard.value`, and `useSyncExternalStore` (`useRootStandard`) lets panes rebuild their kit if faces change. v1.5.1 made adoption **grow-only** (never let a re-render with fewer/empty faces clear it). **Slot capture/re-host kit:** for every pane it captures `conversation.view` (chat/trajectory/…), `conversation.composer.bar`, `conversation.session.header`, `conversation.composer` chain, input seats, dock strips and the per-node chat renderers; it emulates the stock outlet: kit props, entry `inject()` faces, slot-level inject faces (cached in a WeakMap), contextual hook factories (`hookContext`), `useStore`/`actions` per entry, `renderSlot/Chain/List` dispatchers, `data-slot`/`data-slot-id` stamps, per-entry locale binding, and list-kind rendering (v1.5.0) with dispatch parity (v1.5.1). **Native pane components:** `NativeHeader` (stock header → breadcrumb/actions/tabs), `NativeViewArea` (view tabs from the ledger / `conversationViews` hook), `NativeTranscript` (stock `ChatView`), `NativeComposer` (stock `InputBar` + dock strips + queue strip outside the dock line), TUI composers wired to the same input machine, `SlimHeader` for narrow panes. **Fallback components:** `FallbackTranscript` (own markdown/tool/think rendering from session face) and `FallbackComposer` (plain textarea + `session.prompt/command/cancel`). Used when capture is missing or a captured component throws; this is the fail-open path. **Workspace frame:** fixed overlay constrained on 4 edges to the measured conversation column; topbar/canvas/rail absent (rail is only in CSS — the `.dsv-rail` JSX is not rendered anywhere; the rail is effectively dead CSS), canvas + dividers; `.dsv-mode-btn`, `.dsv-drop-ghost`; no portal — the overlay is a plain fixed div (`z-index:60`), the session picker is fixed `zIndex:80`, session-picker outside-click on document capture (`5749-5763`). **Global listeners / drag bridge:** window capture keydown ×2 (`registerGlobalKeys`), document capture IME tracker ×4 (`6138-6151`), per-pane document capture ×5 in the editor guard, drag bridge document capture ×4 (inactive mode), per-pane paste capture, divider pointer capture, dock/title pointer listeners added ad hoc (`3530-3562`, `3744-3760`). All except the editor guard and per-pane listeners are `ctx.effect`-owned. **`apply()` lifecycle:** double-apply guard (`appliedOnce`), one `shell.overlay` registration via `slots.inject`, and a large teardown disposer that releases retained references, disposes editor guards, unsubscribes typing, and clears every module map (`6164-6216`). A second bundle evaluation (HMR) gets fresh module state; the old one is expected to be disposed by Cordis. --- ## 4. Host-breaking vectors (evidence-based) Severity: **M** = must-fix (can freeze/crash/corrupt/brick), **S** = should-fix (fragility/UX), **A** = accept (cosmetic). ### M1. `NativeViewArea` violates the Rules of Hooks — a hook-order crash can only be caught by the workspace-wide boundary Evidence (`client.js:3213-3282`): ```js function NativeViewArea({ prepared, session, fallback }) { const ledger = prepared.viewLedger; const viewsHook = ...headerInjected.useConversationViews...; if ((ledger === null || ledger === undefined) && viewsHook === null) { return h(NativeTranscript, …); // ← returns BEFORE any hook below } if (prepared.viewEntries.length === 0) return h(NativeTranscript, …); if (ledger !== null && ledger !== undefined) { React.useSyncExternalStore(...) // ← conditional hook (3228-3233) } ... const currentView = prepared.store.useStore((s) => s.view); // ← hook (3249) ... if (props.inspect === undefined) { props.inspect = prepared.store.useStore(...) // ← conditional hook (3274-3278) } ``` `prepared` is rebuilt whenever `sessionId`, `session`, `bindingReady` **or `rootFaces` identity** changes (`4288-4291`). A rebuild can flip these branches (ledger present/absent, `viewEntries` length, `props.inspect` defined), and a hook-count change throws `Rendered more/fewer hooks than expected`. The closest error boundary is `WorkspaceBoundary` (`5684-5703`), which latches `failed=true` and renders `null` — i.e. **the entire split workspace (all panes) unmounts**, and because `failed` never resets it stays gone until the overlay entry is remounted. If the crash is deterministic for the persisted tree, every boot repeats it: activate → crash → stock view. Likelihood: medium (needs a `prepared` rebuild mid-life); impact: high (all panes lost, no visible recovery). Minimal fix: hoist every hook above the early returns and make the ledger snapshot hook null-safe, or split the branches into distinct child components (`key`ed by branch identity) so hook sets never change; wrap `NativeViewArea`/`NativeComposer`/`SlimHeader`/`ChatPane` in their own `PaneBoundary`s. ### M2. Leaf lifecycle is only cleaned up on the pane's ✕ button — session swaps and `closeFocused()` leak retained sessions, editor guards and document listeners Evidence: - The only teardown of a pane's session resources is the close-button handler (`client.js:5062-5080`): `releaseSessionReference`, `paneChatStores.delete`, `paneImageIntake.delete`, `paneEditorGuards` retire, typing unsubscribe. - Panes are removed/replaced by paths that never call it: - `treeStore.swapFocus(id)` → `replaceLeafId(state.root, focus, id)` (`943-962`, `883-890`) — used by the sidebar click-to-swap effect on **every sidebar selection change while split** (`5841-5851`). The leaf id changes → `RenderSplit`'s wrapper key changes (`h("div",{key:kid.id}, h(RenderLeaf,{id:kid.id}))`, `5091-5095`) → React unmounts/remounts the pane. Nothing runs the cleanup list above for the old session. - `window.__dshSplitView.closeFocused()` (`5545-5553`) calls `treeStore.closeLeaf(id)` directly — no cleanup at all (used by `dsh-session-hotkeys`). - `movePane` removes then re-adds a leaf (same id → mostly reuses maps, but the typing unsubscribe is overwritten: `paneTypingUnsubs.set(sessionId, unsubTyping)` at `3027` without unsubscribing the previous one). - What leaks per swapped-away/closed-outside-✕ session: - `paneSessionReferences` entry → the host `SessionReference` is **retained forever** (memory + `retainedBy` accounting; the host can never unload that session). - `paneEditorGuards` entry → the guard's `dispose()` is never called, so its `editor.registerRootListener` and **5 document capture listeners** (`focusin`, `pointerdown`, `beforeinput`, `keydown`, `compositionend`, `2967-2971`) stay installed for the rest of the page life. Each new stale guard makes every keystroke/click in the app run one more `guardRoot` (document `querySelector`, `querySelectorAll`, layout rect reads). - `paneTypingUnsubs` subscription, `paneImageIntake` closure, `paneChatStores` store (with listener set) survive. A user who clicks through conversations in the sidebar while split (the README's step 3!) accumulates these without bound → progressive slowdown ("gets weirder the more I use it"), growing heap, and eventually OOM in a long session. Likelihood: high (normal workflow); impact: high (leak/OOM; stale root listeners can also mutate the shared editor root long after the pane left). Minimal fix: one `disposePane(sessionId)` function that does everything `5062-5080` does, called from: `RenderLeaf`/`ChatPane` **unmount effect** (`React.useEffect(() => () => disposePane(sessionId), [sessionId])`), `swapFocus` (old id), `closeFocused`, `closeLeaf`, `movePane`; make the maps keyed to live panes and assert-empty in tests after N swaps. ### M3. Persistence has no validation, dedupe, cap or reset path — stale/duplicate trees re-activate a broken workspace at boot Evidence (`client.js:866-920`, `963-982`): ```js function normalizeTree(value) { if (value.t === "leaf" && typeof value.id === "string" && value.id !== "") return { t: "leaf", id: value.id }; if (value.t === "split" && ... Array.isArray(value.kids)) { const kids = value.kids.map(normalizeTree).filter(...); // no depth cap, no leaf cap, no dedupe ``` - **No dedupe:** `normalizeTree` accepts the same session id in two leaves; `addPane/splitLeaf` guard new duplicates (`963-967`, `983-987`) but persisted data is trusted verbatim. Two leaves with the same id → two `ChatPane`s for one session → two composers mount on the **same** Lexical editor; `paneEditorGuards` is keyed by sessionId so the second guard disposes the first (`2997-3005`), while both pane contenteditables exist; both panes' `paneImageIntake`/`paneChatStores`/keyed state collide. Result: the classic "visible input, typing dead / Enter dead" state, permanently for that tree. - **No validity/ownership check:** ids that no longer exist (or are another profile's) still count toward the ≥2 activation gate (`933`), so the overlay covers the stock conversation column at boot with two "Session ended" panes. Since the plugin renders no exit affordance outside the panes, the only way out is closing panes via their ✕ (if chrome is visible) or clearing localStorage by hand. **There is no kill switch, no "reset split layout" action, no crash counter.** - **Unbounded recursion:** `leafOf`, `removeLeaf`, `splitLeafTree`, `RenderSplit` all recurse over the persisted tree; a deep/hand-edited tree can stack-overflow during render (caught by `WorkspaceBoundary` → workspace gone every boot) or during `commit`. - **No schema version:** `TREE_KEY` is `v1` but nothing checks a version or migrates; `OPEN_KEY`/`RAIL_KEY` are dead. Likelihood: medium (corrupt/hand-edited/stale trees; duplicates unlikely from the UI but possible from older builds or interrupted writes); impact: high (overlay covering stock UI with dead panes; persistent boot-loop of a failed workspace; unwinnable without devtools). Minimal fix: in `createTreeStore`, validate: max depth 6, max leaves 8, unique ids, ids matching `/^[A-Za-z0-9._:-]{1,128}$/`; on violation discard the tree (and localStorage) with one console warning; persist a `schema` field; add a "Reset split layout" action to the pane menu and a `window.__dshSplitView.reset()`; count consecutive `workspace-crash` boundary trips in localStorage and clear the tree after 2; while the list source is available, drop leaves whose session is unknown **only if** the source is trusted (store path), never on the DOM path. ### M4. `offerRootStandard` mutates external state during render and its grow-only refresh can rebuild every pane (re-running inject factories and re-registering editor guards) Evidence (`client.js:1099-1124`, `5715-5718`, `4286-4301`): ```js function offerRootStandard(props) { // called from WorkspaceApp render const next = extractRootStandard(props); ... let merged = null; for (const key of Object.keys(next)) { const value = next[key]; if (value === undefined || rootStandard.value[key] === value) continue; if (merged === null) merged = Object.assign({}, rootStandard.value); merged[key] = value; // ← refresh by identity } if (merged !== null) rootStandard.value = merged; // ← store mutation during render return rootStandard.value; } ... offerRootStandard(props); // in render (5715) React.useEffect(() => { publishRootStandard(props); }); // every render, no deps (5716-5718) ... const rootFaces = useRootStandard(); // ChatPane subscription (4286) const prepared = React.useMemo(() => ..., [sessionId, session, bindingReady, rootFaces]); // 4288-4291 ``` - The **grow-only merge still replaces the store value whenever any face identity changes** (line 1119-1123), and then `publishRootStandard` notifies every pane. Each pane rebuilds `prepared` → `prepareNative` runs again: entry `inject()` factories are invoked again (`2642-2667`, `3072-3116` — the comment at `2631-2634` says explicitly these "can have per-call side effects, so they run exactly once here"), a **new editor guard is installed** (disposing the previous one, `2997-3005` — a gap during which the stock composer can take the root), new renderers/kit identities are handed to re-hosted components, and `useSessionBindingReady`/`useSessionFace` state may churn. - If the root faces are genuinely static (0.2.1 memoizes them per binding), this path should never fire; if the desktop shell does re-render the overlay entry with a *new root binding object* (a second renderer generation, remount, or HMR), `rootStandard.value` keeps the **old** dead faces thanks to grow-only — panes then hold references to a dead generation's hooks (`useSessions` over a disposed list store). "Grow-only" protects against shrinkage but makes staleness permanent, which is exactly the failure class v1.5.1 was trying to remove (the storm was triggered by *lost* faces; the remaining failure is *stale* faces plus churn-driven rebuilds). - Mutating an external store during render is also the pattern React punishes: `useSyncExternalStore`'s getSnapshot changes during the same render pass; this can produce extra render passes and, in combination with per-pane state updates, contributes to update storms (the historical React #185 loop). Likelihood: medium-high on desktop (the v1.5.1 changelog observed the shell re-rendering the entry); impact: high (storm/OOM, or silently stale hooks). Minimal fix: (a) never mutate in render — stash the extracted props in a ref and publish from a layout effect; (b) make adoption **add-only** (ignore identity refreshes entirely; if a face must be replaced, that means a new shell generation → deliberately remount panes by bumping a generation counter that is part of the pane key, so they rebuild cleanly instead of re-preparing in place); (c) cache the prepared bundle against a **stable kit version** so `prepareNative` is not re-run by an identity change; (d) if a root binding generation actually changes, release old session references and rebuild. ### M5. Lexical editor-root contention remains a by-design race (the one *structural* host-breaking vector) Evidence (`client.js:2749-3005`, stock `ui-conversation 0.2.1:18083-18104`): ```js // pane guard const guardRoot = (defer) => { ... const el = paneHost(); // first visible [data-composer-input] in THIS pane const root = paneEditor.getRootElement(); if (el !== null && root !== el) { if (composingIn(el)) return; // IME stand-down if (!mayRepoint()) return; // 250ms spacing / 6 per 10s breaker paneEditor.setRootElement(el); // ← reclaim from the stock page noteRepoint(); ... } else if (el === null) { ... hand root to displacedHost ... } }; offGuard = paneEditor.registerRootListener(guardRoot); document.addEventListener("focusin", guardOnEvent, true); // ×5 per pane ... ``` ```js // stock composer (same in 0.2.0 and 0.2.1) — ONE WRITER assumption function ComposerContentEditable({ editor, editable, ...rest }) { const ref = React.useRef(null); React.useLayoutEffect(() => { const el = ref.current; if (editor === null || el === null) return; editor.setRootElement(el); return () => { editor.setRootElement(null); }; }, [editor]); ``` There is exactly one Lexical editor per session (`SessionInputShell.get editor()`), and both the hidden stock `InputBar` (still mounted under the overlay) and the pane's re-hosted `InputBar` mount a `ComposerContentEditable` for that editor. The plugin's guard is a **reactive theft-recovery loop**, not an ownership protocol: whichever side calls `setRootElement` last wins, and the loser's element becomes an inert `contenteditable` div (text paints, Lexical never sees it → grey placeholder, Enter/send/attachments dead — the documented v1.3 symptom). Residual failure modes that the rate limiter/breaker do not remove: teardown races (stock unmount calls `setRootElement(null)` after the pane claimed it), IME composition windows (deliberately skipped, so the stock side can win and stay winning until the next compositionend/focusin), stale `displacedHost` after session swaps, multiple panes/mount orders, and the breaker's "one final re-point" racing a stock remount. Impact: **the flagship feature (typing in panes) is probabilistic**, and this is the most credible "it breaks my harness" complaint. No bounded patch makes this deterministic. Minimal *robust* fix: stop mounting the native composer in panes — render a plugin-owned composer bound to the shell's `inputActions` (`setDraft`, `submit`, `addFiles`, `paste`, `stop`, `toggleCommandMenu` are all plain actions; TUI composer at `3601-3691` already proves this path) and keep only the transcript re-hosting. If native composer parity is non-negotiable, the alternative is iframe isolation (see §9 and `RESEARCH-2026-10-19.md`). ### M6. Entry `inject()` factories are re-invoked on every render for every captured entry (stock caches them), adding side effects and identity churn to the streaming hot path Evidence (`client.js:2404-2432`, call sites `2497`, `2540`, `2559`): ```js const entryInjectProps = (parts) => { const fn = parts.raw.inject ?? parts.options.inject; if (fn === null) return {}; try { const face = injectFaceProps(fn(sessionId, paneActions)); // ← every render, every entry ``` The stock renderer caches the entry inject result per (entry, scope) — `ui-renderer 0.2.1:769` ("One rendered entry: standard kit + **cached entry inject** + common slot inject + owner props"). The plugin rebuilds it on every render of every entry, including the **per-chat-node renderers** (`conversation.chat.node` via `renderSlot`, called once per node per ChatView render → once per node per streaming batch, N panes × nodes × tokens). `injectFaceProps` also creates new hook functions per call, so props identities change on every render of every captured child, nullifying downstream memoization and increasing commit work. If any inject factory allocates from a store/subscription (they are allowed to — the stock contract caches them), this is an unbounded allocation/duplicate-subscription path. Likelihood: high (hot path); impact: medium-high (storm amplifier, GC churn, occasional duplicate-subscription bugs). Minimal fix: cache per `(entry, sessionId)` in a `WeakMap` exactly like `slotInjectCache` does for slot-level injects (one line at the top of `entryInjectProps`), and clear/invalidate on prepared rebuild. ### S1. Panes' existence is inferred from scraped sidebar DOM — a hidden/filtered/collapsed sidebar row makes live panes render "Session ended" Evidence: `RenderLeaf` (`5044-5052`) derives `exists = sessions.byId[id] !== undefined` from `useSessionsList()`; on 0.2.x that snapshot comes from `createDomSessionListSource` (`1239-1336`), i.e. `document.querySelectorAll('[data-row-key^="session:"], div[class*="_sessionRow"]')` (`1185`, `1203-1228`). `useSessionFace(id, exists)` sets the face to `undefined` when `exists` flips false (`1662-1704`), and `ChatPane` renders "Session ended" (`4302-4305`). Sidebar collapse / list filters / virtualization / the session leaving the visible list / a slow render (the DOM observer may publish an in-between snapshot) can therefore blank panes that are perfectly alive — with no retry beyond the next DOM mutation. The same DOM source feeds `SessionPicker` and `sessionKnownToList` (drag/openTeammate gating). Additionally, the DOM observer is attached to `document.body` with `subtree:true, characterData:true, attributes:true` (`1295-1301`) and runs `readSidebarSessions()` (a full-document `querySelectorAll` + `textContent` per row) behind a 200 ms throttle + rAF (`1237`, `1263-1284`) — bounded, but the trigger source is "any DOM change in the app" while split is active. Impact: should-fix (panes look dead / data-loss scare). Fix: treat the list snapshot as **advisory**: never clear an existing session face because the list lost the id; only clear when the session face itself reports removal/ended, and keep the last-known summary for the pane header; optionally add a one-shot sidebar `querySelector` probe before declaring ended. ### S2. `WorkspaceApp`'s sidebar-selection effect turns every aria-selected change into a pane session swap Evidence: `5841-5851`: ```js const domCurrent = useDomSelectedSession(active); const current = sessions?.current ?? domCurrent; React.useEffect(() => { if (!active) return; if (current === undefined || current === null) return; treeStore.swapFocus(current); // ← replaces the focused pane's session }, [active, current]); ``` `useDomSelectedSession` (`1368-1396`) watches `attributes:["aria-selected"]` on `document.body`. Any programmatic selection change — Agent Teams navigation, session-hotkeys, stock "open in main view", or another plugin's actions — silently swaps the focused pane's conversation. That is surprising, causes the M2 leak on every swap, and can replace a pane mid-stream. Impact: should-fix. Fix: only swap on a genuine user gesture on a sidebar row (document capture `pointerdown`/`click` on `[data-row-key^="session:"]`, then read the id), and make the behaviour configurable/opt-out. ### S3. Error-boundary coverage gaps: pane-level failures take the whole workspace down, and `PaneBoundary` latches forever - Wrapped by `PaneBoundary` (`2597-2625`): captured slot components in `renderSlot`/`renderSlotChain`/`renderList` (`2524`, `2549`, `2583`), the stock header (`3208`), the chat view (`3185`, `3281`), the composer bar (`3376`), the composer chain (`4377`), hero seat — via the renderers. - **Not wrapped:** `WorkspaceBody`, `Canvas`, `RenderSplit`, `RenderLeaf`, `PaneFrame`, `ChatPane`, `NativeViewArea` (its own hooks, M1), `NativeComposer`'s own render (calls `renderList` before the bar), `SlimHeader`, `DockLine`, `DsvInputDock`, `SessionPicker`, `FallbackTranscript`/`FallbackComposer`, `TuiMachineComposer`. A throw anywhere in these reaches `WorkspaceBoundary` → **all panes unmount, latch `failed=true`** (`5684-5703`), with one console.warn. There is no retry, no per-pane isolation, and no UI trace (the overlay silently disappears; users interpret it as the app/proview freezing). - `PaneBoundary` also latches (`state.error` never resets, `2618-2623`) and most slots pass `fallback:null` → a transient render error permanently removes that row for the pane's life with only a console line. - Impact: should-fix (single failure domain = whole feature). Fix: wrap each `PaneFrame`/leaf in its own boundary with a visible fallback ("this pane failed — click to retry") whose `key` includes session/binding generation; reset slot boundaries when `prepared`/`rootFaces`/session changes; never swallow the reason silently (show a badge; expose `window.__dshSplitView.lastError`). ### S4. Observer/layout budget: several layout-thrash loops stay inside the DOM-observed hot path - `PaneFrame` installs a `ResizeObserver` on the frame plus a **`MutationObserver` on the frame with `{childList:true, subtree:true}`** (no marker filter) whenever the composer is in full mode (`4573-4700`, observers 4688-4699). Every streaming commit in a pane schedules an rAF that runs `querySelector` + `querySelectorAll` + `getBoundingClientRect`/`getComputedStyle` (`4582-4685`). The cheap-gate at 4588 helps, but it is still per-pane per-commit, and the *stock* ChatView under the overlay streams in parallel for the main conversation (double rendering, §S5). - Each `DockLine`/`DsvInputDock` runs `useComposerAlignedRow` (`3431-3497`): a 12-tick rAF settle loop, a `ResizeObserver` on the pane, a `MutationObserver` on the pane subtree with attribute filters, and `visibleComposerRect` (`3407-3422`, `querySelectorAll` + rect per node) per tick. `DockLine` adds its own `ResizeObserver` (`3518-3524`). Multiplied by N panes × dock rows. - The user's crash log pairs the 0.2.0-era OOM with "ResizeObserver loop completed with undelivered notifications" — consistent with layout-observer feedback during the storm, not proof, but the loop budget is the right thing to cut. - Impact: should-fix (steady CPU/GC cost, contributes to the freeze class); Fix: observe only the composer card node (not the frame subtree), gate measurements on `document.visibilityState`/`active`, stop measuring during streaming (`session.running`) unless geometry actually changed, merge all pane observers into one shared `ResizeObserver`, and replace the settle loop with observer-driven updates. ### S5. Overlay leaves the covered stock UI focusable and streaming Evidence: `.dsv-overlay` is `position:fixed; z-index:60` covering the conversation column (`165-180`); the stock frame columns remain in the DOM, fully interactive, with no `inert`/`aria-hidden` applied by the plugin. Consequences: Tab/Shift+Tab can move focus into invisible stock controls (model chip, hero, composer), screen readers still announce the covered conversation, the stock ChatView keeps rendering/streaming under the overlay (double work, hidden text updates), and stock portaled UI (toasts, dropdowns, Agent-Teams panel at z-index 100) may appear positioned relative to hidden stock anchors. Impact: should-fix (a11y + invisible-focus bugs + cost). Fix: while active, mark the stock conversation column `inert` (set/remove via the effect, restore on any failure/teardown); this is safe only if the plugin's borrowed-binding fallback (`resolveSessionBinding`'s `uiSession.current` borrow, `1457-1462`) is not needed for the main session — audit first, and always remove `inert` from the disposer boundary/top-level boundary. ### S6. Inactive-mode drag bridge intercepts *all* `text/plain` drags over the conversation column Evidence (`client.js:5984-6066`): ```js const sessionDragInFlight = (event) => { if (dragPayload !== null) return false; const types = event.dataTransfer?.types; ... if (types.includes("Files")) return false; return types.includes("text/plain"); // ← any text drag, not just a sidebar session }; const onDragOver = (event) => { if (treeStore.isActive()) return; if (!sessionDragInFlight(event)) return; const rect = columnRect(); ... event.preventDefault(); // ← claims the drop target ``` While the plugin is enabled but no split is active, dragging any text (selected chat text, a link, text from another app) over the conversation column gets `preventDefault()` on dragover, so the browser treats the column as a drop target; the drop handler then returns unless the payload is a known session id (`6053`) — the text drop is silently swallowed. Drop onto the stock composer/editor can therefore stop working whenever a split view has ever been enabled. Impact: should-fix (real, intermittent stock-UI regression). Fix: only claim drags whose source is provably a sidebar session row — e.g. register a custom MIME (`application/x-dsv-session`) on the sidebar row via a capture-phase `dragstart` listener, or check `event.dataTransfer.getData`/`types` for that MIME; leave plain `text/plain` to the browser. ### S7. Global chords are capture-phase and `stopPropagation()`-ing, competing with host/companion plugins `onPaneNavKeydown` / `onComposerModeKeydown` (`5476-5520`) run on `window` capture, check only `altKey/shiftKey`, and on match call `preventDefault()` **and** `stopPropagation()` (5481-5483, 5508-5509). Alt+T / Alt+Shift+Arrows are not reserved host chords, but they are common in other DSH plugins (the profile bundles `dsh-session-hotkeys`, `dsh-result-only-view`, `dsh-cost-meter`, `@xmanrui/dsh-im`); a capture-phase swallow means plugin co-existence is arbitrary (order-dependent), and the user cannot rebind. Impact: should-fix. Fix: register through the host shortcut service (the layout package uses `ctx.shortcuts.register`, `ui-layout 0.2.1:654`) or at minimum don't `stopPropagation()`; degrade when another handler already consumed the event. ### S8. Composer takeover is dead on 0.2.x (see D1) — implementer-facing "silent feature failure" Not host-breaking, but it is the most user-visible functional lie in the README ("Questions and approvals land in the pane"). Fix: `const useSessionStatus = rootFaces?.useSessionStatus` (already harvested) → `useSessionStatus((snap) => snap?.get?.(sessionId)?.pendingInteraction)` in `ChatPane`, and feed that to the chain owner. Add an integration assertion (mock root kit with a sessionStatus Map) so it cannot silently regress. ### S9. Minor/accept items observed - **Dead CSS/keys**: the rail *container* rules `.dsv-rail`, `.dsv-rail-list`, `.dsv-rail-empty` (`203-231`) have no JSX consumer (no rail is rendered; the session picker is a fixed dropdown that reuses only `.dsv-rail-search/item/dot/text/title/sub` in `SessionPicker` at `5269-5298`), and `OPEN_KEY`/`RAIL_KEY` (`77-78`) are defined but never read/written. Also worth noting: the plugin itself uses **no React portal** anywhere (`createPortal` count 0); its overlay and picker are plain `position:fixed` elements (z 60 / 80), so stock plugins that portal at z>60 render above the overlay — intended for Agent Teams, but also true for any stock toast/dropdown. Dead code/constants increase review surface. (A) - **Hardcoded English strings** in a localized plugin: "TUI composer (Alt+T)" (`4742`, `5000`), "Running…", "Completed", "Failed", "Stopped", "Enter ↵", "Think", "×" etc. The i18n registration covers only the `EN`/`ZH` tables. (A→S for a Chinese-first user) - **Queue-strip heuristic** introspects React element internals (`props.children.type.name`, `3349-3369`) to find `QueueDock`; a memo/lazy wrapper or minification rename silently misroutes the queue strip into the draggable dock line (`3530-3562`). (S) - **Pane mode "tui" auto-switch** is height-based with hysteresis stored in a ref (`4528-4544`), not persisted; reloading a saved layout changes a short pane's composer unexpectedly. (A) - **`translate` wrapper chain grows on every apply** (`6127-6134`: `previous = translate`), and its locale binding can outlive the locale service; a couple of reloads leak closures and may throw inside the wrapper (caught). Reset to `EN` fallback in the teardown. (S, lifecycle hygiene) - **`window.__dshSplitView` is never removed on unload** (`1588`, `5539-5554`): stale bridge functions can still mutate `treeStore`/localStorage after the plugin is gone (the tree store is module-level and persists by design). (S) - **Composer-mode toggle overlaps the stock stats band** on unusual card geometry (fallback corner `4726-4735`); guarded but geometry-dependent. (A) - **`chatScrollMemory`, `captureLog`, `paneDebug`, `paneKitWarned`** are capped/cleared appropriately (good), but `chatScrollMemory` grows per session for the page life; cap it like `paneDebug.faces` (32). (A) --- ## 5. Status of previously documented root causes (REVIEW-2026-10-03 A–N) | Root cause | Status in current file | Evidence / note | |---|---|---| | A overlay physically covers stock chrome; auto-add-current at boot | **Fixed** | split-only gate (`933`, `5723`, `5878`); auto-add effect removed (comment 5853-5860) | | B geometry mis-measure covers sidebar or fails to activate | **Fixed/partially** | 4-edge bounds, fail-closed (`5572-5733`); v1.5.1 `frameColumns` skips `[data-shell-overlay]` and absolute/fixed children (`5587-5601`) — correct for the 0.2.1 shell (frame children include overlay layer + splitter). Residual: still structural guesses; a new static child could be misread as the details column. | | C full-document DOM scrape + innerText forced reflow storm | **Fixed** | `textContent` (`1223`), rAF + 200ms throttle (`1237`, `1263-1284`), observer only while active (`1320-1324`, `5728-5733`). Note: still a body-wide observer with characterData while active, and M2 leaks make *stale* code paths more costly, but the storm trigger is gone. | | D Lexical root ping-pong without rate limit | **Mitigated, not fixed** | 250ms spacing + 6/10s breaker + fail-visible final reclaim + give-back path (`2826-2930`); the underlying single-writer contest remains (M5) | | E per-render forced layout (FallbackTranscript no-dep effect, PaneFrame subtree observer, 90-tick settle) | **Mostly fixed** | deps on `FallbackTranscript` (`4041-4046`); settle 12 ticks + stable exit (`3440-3477`); TUI skips observers (`4580`). Remaining per-commit measure loops (S4) are bounded but present. | | F module-level listeners/maps survive unload; double key registration | **Fixed for apply-owned paths** | keys in `ctx.effect` (`6158`, `5523-5530`), teardown clears maps (`6164-6216`), `appliedOnce` (`6090`). **But pane-owned paths leak** (M2: guards/listeners on unmount-by-swap) and `window.__dshSplitView`/`translate` persist. | | G WorkspaceApp has no error boundary | **Partly fixed** | `WorkspaceBoundary` (`5684-5703`); coverage gaps remain (S3) and it latches without recovery | | H heavy glow (cosmetic) | **Fixed** | CSS comment 304; single border/shadow | | I missing `data-slot` stamp | **Fixed** | list (`2580`), chain (`2546`), single path inherits kit/owner (`2502-2524`); v1.5.1 added dispatchers for list entries (`2567-2568`) | | J DockLine mousedown preventDefault eats focus | **Fixed** | engage after ≥3px, no preventDefault (`3536-3554`) | | K breaker parks root on hidden stock composer | **Mitigated** | fail-visible final re-point + give-back (`2873-2920`); still a race (M5) | | L list slots rendered as single | **Fixed** | `slotKindOf` + `renderList` (`2465-2474`, `2483-2487`, `2553-2586`) | | M openTeammate mainView guard | **Fixed** | `splitAwareOpenTeammate` (`5962-5982`), wrapped in `entryInjectProps` (`2421-2423`) | | N SlimHeader drops actions / TUI no header | **Fixed by design** | actions re-hosted (`3731-3763`), TUI headerless by design (`4316-4320`) | **Documented-causes verdict:** v1.4/v1.5 fixes are real and present. The user-visible breakage persisting after them is therefore *not* the old root causes but the deeper ones: M1-M6 (hook-order/rebuild/leak/root-contention/entry-inject churn) plus persistence/activation hazards (M3) and DOM-truth (S1). --- ## 6. New root causes (not in REVIEW-2026-10-03) 1. **Partial teardown / leaked pane resources on session swap (M2)** — the v1.4.0 "lifecycle收敛" only covered `apply()`-level registries and the ✕ button; tree-driven leaf replacement bypasses it. This is the strongest new explanation for "gets worse the longer I use it". 2. **Hooks-rules violation in `NativeViewArea` (M1)** — introduced with the multi-view-tab feature; its failure mode is a whole-workspace unmount, invisible except a console warn. 3. **Grow-only harvest converts the 1.5.1 shrink storm into stale-generation faces (D4/M4)** — and the merge still churns on identity changes, so `prepareNative` can re-run (side-effecting injects + guard replacement). The fix is directionally right but incomplete: it needs an explicit renderer-generation concept. 4. **Entry `inject()` re-invocation per render (M6)** — v1.5.0/v1.5.1 added entry-inject support but not the stock caching; this sits on the chat-node hot path. 5. **Persisted tree as an unvalidated capability grant (M3)** — 2 strings in localStorage decide whether the plugin covers the stock UI at boot; no schema, dedupe, cap, reset or crash counter. 6. **DOM sidebar as the source of truth for session existence and selection (S1/S2)** — 0.2.0 accommodation that became a permanent correctness dependency. 7. **Covered stock UI left interactive/streaming (S5)** — double rendering and invisible focus. 8. **Drag bridge's over-broad `text/plain` claim (S6)** — a stock-UI regression while the plugin is merely installed. --- ## 7. Risk / severity table with minimal fixes | ID | Finding | Sev | Likelihood | Impact | Minimal fix (must-fix items) | |---|---|---|---|---|---| | M1 | Conditional hooks in `NativeViewArea` (`3219-3281`) → hook-order throw → `WorkspaceBoundary` kills all panes | **M** | Medium | High (workspace gone; boot-loop if deterministic) | Hoist all hooks above early returns (null-safe ledger subscription), or split into branch-specific child components; add per-pane boundary + retry key | | M2 | Leaf swap/close paths leak retained refs + editor guards + 5 doc listeners + subs (`943-962`, `5062-5080` only on ✕, `5545-5553`) | **M** | High | High (unbounded leak/OOM, stale root listeners) | One `disposePane(id)` called from ChatPane unmount effect + `swapFocus`/`closeFocused`/`closeLeaf`/`movePane`; test map sizes after N swaps | | M3 | Persistence: no dedupe/cap/validation/reset/crash counter (`866-920`, `933`) | **M** | Medium | High (dead panes over stock UI; duplicate panes share one editor; no escape) | Validate+dedupe+cap on load, schema field, discard on invalid, reset action + `window.__dshSplitView.reset()`, auto-clear after 2 boundary trips | | M4 | Harvest mutates store in render; identity refresh rebuilds all panes, re-runs side-effecting injects + guard replacement; stale faces on generation change (`1099-1124`, `5715-5718`, `4286-4301`) | **M** | Medium | High (storm/OOM/stale hooks) | Publish from effect; add-only adopt (no identity refresh); stable kit version; explicit renderer-generation remount path | | M5 | Editor-root single-writer race with hidden stock composer (`2749-3005` vs `ui-conversation:18083-18104`) | **M** | High | High (dead/untouchable composer) | **Structural**: replace native composer re-hosting with plugin-owned composer wired to `inputActions` (TUI composer pattern); or iframe per pane. Keep native transcript. | | M6 | Entry `inject()` re-invoked per render per entry (`2404-2432`, `2497/2540/2559`) | **M** | High | Medium-High (storm amplifier/identity churn/dup subscriptions) | Cache per `(entry, sessionId)` in a WeakMap; invalidate on prepared rebuild | | S1 | Pane existence from scraped sidebar DOM (`1239-1336`, `5048-5052`, `1662-1704`) → "Session ended" for live sessions | S | Medium | Medium (looks broken/data-loss scare) | Sticky last-known face/summary; only clear on session-reported ended | | S2 | Any `aria-selected` change swaps focused pane (`5841-5851`, `1368-1396`) | S | Medium | Medium (surprise session swap + M2 leak) | Swap only on user gesture on a sidebar row; make configurable | | S3 | Boundary coverage gaps + `PaneBoundary` latch (`2597-2625`, `5684-5703`) | S | Medium | High when hit (all panes) | Per-pane boundaries with visible retry; reset on prepared/gen change; expose last error | | S4 | Per-commit measure loops (`4573-4700`, `3431-3497`, `3510-3568`) | S | High | Medium (CPU/GC, freeze contributor) | Observe card nodes only; skip while streaming; one shared RO; observer-driven settle | | S5 | Covered stock UI still focusable/streaming; no `inert` (`165-180`, no attribute management) | S | High | Medium (a11y, invisible focus, double cost) | Apply/remove `inert` on the covered column while active, always removed by boundary/teardown | | S6 | Drag bridge claims all `text/plain` drags while inactive (`5984-6066`) | S | Medium | Medium (stock text drops swallowed) | Claim only drags carrying a sidebar-session MIME/gesture marker | | S7 | Global chords swallow events via capture+stopPropagation (`5476-5520`) | S | Low-Med | Medium (plugin conflicts, no rebind) | Host shortcut service / no stopPropagation | | S8 | Composer takeover dead on 0.2.x (`1745-1769` vs `ui-conversation:17747`) | S→M for feature trust | High | Medium (headline feature silently absent) | Use harvested `useSessionStatus`; integration test | | A1 | Dead rail CSS/keys, i18n gaps, queue-strip reflection heuristic, toggle geometry, closure chain, stale bridge global | A | — | Low | Cleanup/consolidate as part of the next refactor | --- ## 8. Maintainability & test-gap assessment **Maintainability:** poor, and the trend is negative. - 6,236 lines in one factory, no types, no source maps, comment density high but behavioural knowledge is only in comments (several already stale — e.g. "runs exactly once here" at `2631`, "the rail" CSS, `OPEN_KEY` unused). - The plugin emulates a proprietary runtime: slot dispatch semantics (single/list/keyed/chain, `entryKey`, `hookContext`, `data-slot` stamps), standard-kit synthesis (`FRAMEWORK_SEATS`), scope bindings, per-entry stores, locale binding, DOM markers, Lexical internals (`__lexicalEditor`). Every DSH minor release can break it silently; the 1.5.1→live scenario shows how hard that is to notice (the CI/test suite cannot see the host). - The v1.5.1 fixes are symptom patches for a race whose source is invisible to the author; the next such report will be the same exercise. **Test gaps (concrete):** - `tests/smoke.mjs` (16 assertions) covers: bundle eval under a stub DOM, apply once + double-apply guard, listener registration/removal, activation gate on keydown. It does **not** render React, does not exercise any slot capture, editor guard, persistence normalization, leaf lifecycle, or host contract. - No React rendering tests → M1 (hook order) cannot be caught; no teardown assertions on maps → M2 cannot be caught; no `normalizeTree` fuzz cases → M3 cannot be caught; no 0.2.1 fixtures → D1/D3 are invisible. - Recommended additions (cheap, non-live): 1. `node:test` + `react-test-renderer`/jsdom harness with a fake slot service + fake session binding; render `WorkspaceApp` with a 2-leaf tree, swap sessions, close via both paths; assert all module maps are empty and document listener counts return to baseline (instrument `addEventListener`). 2. Hook-order test: render `NativeViewArea` with `prepared` variants (ledger null → object; viewEntries empty → non-empty; inspect defined/undefined) and assert no throw. 3. `normalizeTree` property tests: duplicates, deep nesting, huge kids, wrong types, non-string ids → always returns a bounded valid tree or null; explicit dedupe assertions. 4. Contract fixture test generated from the extracted 0.2.1 packages: assert the exact `FRAMEWORK_SEATS` set, slot kinds for the keys used, `entriesOfSlot`/`spec` behaviour, `standardKit` seats, and the pendingInteraction source (`useSessionStatus`) — fail the test when a new DSH package drops/renames these. 5. A local static check that `client.js` contains no `React.use*` call after a conditional `return` (regex/AST) — the cheapest guard against M1-class regressions. 6. A “storm budget” harness: simulated session store emitting every 16 ms; assert renders/commit counts and layout-read counts stay under a per-second ceiling when panes stream. --- ## 9. Architecture viability verdict **Can patch-hardening reach acceptable robustness? No — not for the current design.** - The must-fix items M1-M4/M6 are fixable in-file with bounded effort; they address the "whole workspace dies", "grows weird over time", "boots into a broken overlay", and "rebuild storm" classes. Fixing them would remove most *freeze/OOM* risk. - **M5 is structural, not patchable.** The plugin cannot own a single-per-session Lexical editor while the stock composer for the same session stays mounted under the overlay; a reactive guard has an irreducible race window (stock mount/unmount, IME, teardown ordering, multiple panes). Every hedge (rate limit, breaker, fail-visible re-point, IME stand-down, visible-host probe) reduces frequency, not the class. The user's memory of "v1.3.0 untypeable composer" is the same mechanism; v1.4.1 only lowered its probability. - The alternative that removes M5 entirely is to **not re-host the stock composer**: keep native transcript/header re-hosting (read-mostly, lower risk) and render a plugin-owned composer wired to the shell's public action surface (`setDraft`, `submit`, `paste`, `addFiles`, `stop`, `toggleCommandMenu`, draft snapshot via `state.subscribe`/`getSnapshot`), i.e. generalize the existing `TuiMachineComposer` (`3601-3691`) to the full composer. Cost: visual parity work; benefit: the entire editor-guard/IME/focus/dock-alignment subsystem disappears (≈700 lines and the biggest failure class). - If frame-perfect native composer parity is a hard requirement, the only known robust architecture is **isolation per pane (same-origin iframe per pane, full stock client pinned to a session)** — see `docs/RESEARCH-2026-10-19.md` §11 and the iframe candidates. It trades memory (N app instances) for crash/freeze containment. - **Residual risk after M1-M4/M6 (=patched current architecture): moderate-high.** Composer dead/untouchable remains a recurring probability (M5); DOM-contract drift remains a silent breakage channel; host memory grows with retained sessions until M2 is fixed (then bounded); renderer stays sensitive to root-generation changes. Recommendation to the user: if the current path is kept, ship it with a visible "split view failed / reset layout" kill switch and treat the native-composer re-hosting as experimental. - **Residual risk after the composer redesign: low-moderate** (native transcript re-hosting + plugin composer + fixed lifecycle/persistence). **After iframe isolation: low** for host stability (host React never shares a renderer/editor), moderate for memory/UX. --- ## 10. User-friendliness gaps (for the implementer) 1. **Starting a split is a hidden gesture.** With ≤1 pane the plugin renders *nothing*; the only entry point is dragging a conversation from the sidebar onto the conversation column (`5984-6066`). The split buttons appear only inside pane headers, which only exist once a split already exists. If the drag bridge breaks (S6/D6) there is no fallback UI and the plugin is unreachable. Add a discoverable entry (e.g. a small overlay button in `shell.overlay` that is allowed to render when inactive but only as a launcher, or a sidebar/hotkey action) and update README's Getting Started accordingly. 2. **No kill switch / recovery UX.** If the overlay appears over dead panes (M3) or the workspace boundary trips (S3), the user gets a console warning and a stock view that may be covered; there is no in-app "Reset split layout / disable split view" action, no visible error badge, no `window.__dshSplitView.reset()`. Given the user's history, a one-click escape hatch is mandatory. 3. **Silent failure modes everywhere.** Unconfident geometry → workspace silently invisible (one console warn); capture failures → silently swapped to fallback renderer; `PaneBoundary` fallbacks are `null` for most rows; ASK/approval cards (S8) silently absent. Ship a user-visible status line ("split view unavailable: could not measure the conversation column") and a debug toggle that surfaces `__dshSplitView.composerChain`/`paneDebug`. 4. **Persistence is confusing and sticky.** The layout survives reloads including panes on stale/dead sessions, and there is no indication in the UI of what was restored or how to clear it. Show a one-time "restored 2-pane layout — Reset" toast; validate; add a schema version. 5. **Shortcut conflicts.** Alt+T/Alt+Shift+Arrows are undocumented outside README and swallow events app-wide (S7); users with `dsh-session-hotkeys` will experience arbitrary behaviour. Register through the host shortcut service and expose the bindings. 6. **i18n gaps** in a Chinese-first product: hardcoded English strings listed in S9; also mixed `EN` fallback when locale binding fails. 7. **The README overclaims.** "Questions and approvals land in the pane" is false on 0.2.x; "the plugin's steady-state cost is zero" is true only while inactive but leaks while active (M2); "if the workspace component throws it unmounts itself and the stock view keeps the viewport" is true but the user has no way to tell it happened. Align README with behaviour and add a compatibility statement (tested on 0.2.0 only; live host 0.2.1). --- ## Appendix A — Reference: live 0.2.1 contract evidence used above All paths under `\core-0.2.1\dsh\node_modules\@deepseek-ai\`: - `dsh-client-modules/lib/client.js` — seed-first require 833-841; inject rows only for graph rows 776-794; registered-factory materialization 803-830; graph row metadata 127-139. - `dsh-web-frontend/dist/assets/index-CN7NIWNw.js` — `function SZ()` static seed table (react, cordis, zod, @tanstack/react-virtual, dsh-client-store, dsh-client-ui-slots, dsh-client-ui-primitives, dsh-client-ui-dockkit). - `dsh-client-ui-renderer/lib/client.js` — slots service 1347-1348; `inject` 1368; `provideRoot` 1465; `installScope` 1491; `entries` 1542; `entriesOfSlot` 1554; `spec` 1582; `_register` 1603; standardKit 736-767; standardProps 666-702; scope contexts 182-209. - `dsh-client-ui-layout/lib/client.js` — shell.overlay declaration 643-646; rendered 313; `data-shell-overlay` 351; shortcut registration 654. - `dsh-client-ui-session/lib/client.js` — root contribution 1104-1113; `sessionStatus` 782-790; `pendingSnapshot`/`publishPendingInteractions` 773/933; `registerPendingInteraction` 876-893; `PendingInteractionDomain` 722. - `dsh-client-ui-conversation/lib/client.js` — `useSessionStatus((snapshot)=>…pendingInteraction)` 17747; composer chain owner 17846-17849; approval chain select 344-347; question chain select 1915-1917; `ComposerContentEditable` mount/unmount root writes 18083-18104; `SessionInputShell.get editor` 14907-14910; composer inject face 19921-19944; slot declarations 19742-19919. - `dsh-client-ui-chat/lib/client.js` — `ChatView` signature 2744; chat.node render call with `{entryKey, hookContext}`. - `dsh-client-ui-workspace/lib/client.js` — session row `data-row-key` 2275; drag payload `text/plain` 2288. - 0.2.0 comparison tree: `\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai\` (ChatView signature 5130, `ComposerContentEditable` 16552, `provideRoot` 469-475, composer inject face 18334+). ## Appendix B — Hazard index (current file line numbers) `933` activation gate · `963-1013` tree mutations (no lifecycle hooks) · `1099-1124` grow-only harvest (render mutation) · `1239-1336` DOM list source · `1410-1434` retained refs · `1662-1704` session face ⇄ `exists` · `1745-1769` wrong pending-interaction source · `2002` guard map · `2404-2432` per-render entry inject · `2475-2586` dispatchers · `2597-2625` PaneBoundary · `2635-3130` prepareNative (side-effecting) · `2749-3005` editor guard (5 doc listeners) · `2853-2930` guardRoot · `2967-2971` guard listeners · `2997-3005` guard replacement · `3213-3282` conditional hooks · `3431-3497` settle loop · `4286-4301` prepared memo + session.open · `4339-4379` composer chain (pendingInteraction) · `4449-4458` hero seat · `4573-4700` card measure observers · `4878-4952` paste capture/image fetch · `5044-5083` RenderLeaf + cleanup-only-on-✕ · `5091-5095` key = sessionId (remount on swap) · `5545-5553` closeFocused (no cleanup) · `5587-5648` geometry · `5684-5703` WorkspaceBoundary (latch) · `5771-5834` bounds observers · `5841-5851` aria-selected swap · `5878-5899` overlay render gate · `5984-6066` drag bridge · `6092-6225` apply · `6164-6216` teardown · `6232` service inject list. ## Appendix C — Open questions / unverified assumptions 1. Live renderer behaviour (does the workspace actually mount on 0.2.1? does the editor guard trip?) — owned by the diagnostician; this audit is static. 2. `sessions.retain(id, {source})` / `sessions.scope(id)` signatures on 0.2.1 (`dsh-api-session-controller` not in the extracted tree) — verified only by `ui-session` usage; needs a contract fixture before the next release. 3. Which exact shell path re-renders the plugin's `shell.overlay` entry with an empty standard (the v1.5.1 observation) — the grow-only fix treats it, but its cause (second renderer generation? desktop shell re-composition? HMR?) is still unknown and matters for M4's correct fix. 4. Whether the 2026-10-19 exit-143 host log is the plugin's fault, the app updater's, or a user quit — no direct evidence links it to the plugin; the plugin's known failure class is renderer OOM/freeze, not host SIGTERM. 5. Whether any other installed profile plugins (`dsh-session-hotkeys`, `dsh-cost-meter`, `dsh-result-only-view`, `@xmanrui/dsh-im`) call `window.__dshSplitView` in ways that assume its internals.