chendefine/dsh-sidebar-vscode

dsh-sidebar-vscode

DSH plugin: a better-sidebar tab embedding the VS Code web workbench at the session workspace; editor selections and explorer files land as atomic reference chips

미분류deepseek-harnessdshdsh-better-sidebar

dsh-sidebar-vscode

中文 · npm · GitHub

An official right-Sidebar tab type for DeepSeek Harness (DSH) that embeds the VS Code web workbench (ctx.sidebarRightTabs + the sidebar.right.pane.tab seat of @deepseek-ai/dsh-client-ui-sidebar-right), and turns editor selections / explorer files into atomic reference chips in the conversation composer — expanded by the host half into model context right after the citing message on submit. Its user-facing settings derive from the plugin entry's volatile Config schema and are edited on the official Plugins page: 设置 → 插件 → dsh-sidebar-vscode 的配置区.

Screenshot

Product usage screenshot

editor selection                    explorer
  right-click / Ctrl+Alt+C          right-click file / folder
        │                                  │
        └──────────► atomic chip ◄─────────┘
                  @src/main.ts L10-L12   @src/main.ts   @src
        │                                  │
        ▼ on submit                        ▼
<text-selection path line …>    <file-selection path/> / <folder-selection path/>
  (carries the capture snapshot      (path only, no content)
   and freshness flags)

Features

The tab

  • Registers the official right-Sidebar page tab type vscode (two stages: the static definition into ctx.sidebarRightTabs — guide-page entry box included — and the body into the keyed sidebar.right.pane.tab seat), embedding the code serve-web workbench in a same-origin iframe opened at the current session's workspace (<base>/?folder=<mapped path>). The official pane renders only the ACTIVE tab's body — and an iframe removed from its parent loses its browsing context (HTML spec: the removing steps destroy the child navigable; verified against Chromium 151 — even same-document re-parenting reloads it) — so the workbench does NOT live in the tab body: workbenchRuntime.ts keeps it in a persistent host under document.body that never leaves the DOM, and the body renders a placeholder the host's box is projected onto (see The persistent workbench below). Switching to a sibling tab and back is therefore free — the frame, its WebSocket and its editor state never noticed; a workspace / serverUrl / pathMap change reloads the frame in place; closing the tab (record removal, i.e. the abort signal) destroys the workbench, as does plugin unload.
  • The toolbar shows the workspace path with reload / open-in-new-window actions; all chrome follows the DSH appearance (light / dark / system); copy is bilingual (zh/en).

Reference injection

  • Selection references: select code inside the embedded VS Code, right-click "DSH: Send Selection to Session" (中文界面:「DSH: 发送选中代码到会话」) or press Ctrl/Cmd+Alt+C — the selection lands in the composer at the current caret as one atomic chip (@src/main.ts L10-L12; one backspace deletes it whole; a composer selection is replaced); multi-cursor selections produce one chip each in editor order. On submit the host half expands it at agent/pre-step into a standalone context message right after the citing one:

    
    <text-selection path="src/main.ts" line="L10-L12" lang="typescript">
    const a = 1
    const b = 2
    const c = 3
    </text-selection>
    
  • Explorer file/folder references: right-click files/folders in the explorer ("DSH: Send File/Folder to Session"; multi-select aware, the file vs. folder entry is picked by the right-clicked item) — each item lands at the composer's current caret as one atomic chip: @src/main.ts for a file, @src for a folder. Resource references carry no content: on submit they expand into content-less, guidance-free markers — the tag name itself expresses the file/folder kind, and the model reads the bytes only when it needs them:

    <file-selection path="src/main.ts"/>
    <folder-selection path="src"/>
    
  • Reference management: a rail above the composer groups every chip by reference (truncated / folder badges, occurrence counts); its × removes all chips of that reference through one draft write. A chip's serialized form is a self-contained canonical mention — the draft text is the single store of truth; delete it all and nothing is injected, with no leftover state.

  • Fallbacks & recovery: with a cross-origin serverUrl the same-origin bridge is unavailable — the envelope lands on the real clipboard and pasting it into the composer still recognizes it as chips, landing them at the paste caret; when the input machine refuses a chip insert (mid-submit transient) the mention degrades to plain text (the host parses it identically, only the chip affordance is lost); copying a rendered reference and pasting it back — even as whitespace-mangled sigil text (@ [ label ]( dsh-vscode: … )) or a truncation that lost the closing paren — is re-validated canonically and rebuilt as atomic chips at the caret with the surrounding prose kept verbatim (fail-soft, never throws).

  • The openAsDefault switch gates the three file-open takeovers below (the better-sidebar-era "swap the fresh session's seeded Files tab" behavior is gone with that system — the official sidebar seeds the guide page, keeps layout in memory only, and its openTab always expands the column, so the guide-page entry box is the discovery path for new sessions).

  • Right-sidebar expand-button takeover (gated by the same switch): the conversation header's expand control while the column is collapsed (the official ExpandButton, the one node carrying data-sidebar-right-expand) stock-expands to whatever tab was last active — on a fresh surface that is the seeded guide (with several registered guide entries the official seed rule always resolves to the guide). With the switch on, this plugin captures that button's clicks at the document's CAPTURE phase and re-issues them as openTab('vscode'): the session's ONE workbench page tab is revealed or created and the column expands in the same step (the open's own expansion subsumes the setExpanded(true) the button would have run; the click is consumed — stopPropagation + preventDefault — only after the open succeeded, and a failure lets the stock expand proceed). The button acts on the per-session store directly with no public service seam, so the DOM capture is the one stable interception point; the switch is read per click, and off restores the official behavior completely.

  • Chat file-click takeover (gated by the same switch): clicking produced-file chips (the per-turn changed-files row), tool-row path links, or prose file mentions in the conversation opens the file inside the embedded VS Code instead of the stock viewer — the official runtime routes every one of those opens through the ctx.sidebarRight.openResource public funnel (ui-chat's injected openFile, the deliverables row's chips, prose file links), and this plugin wraps that ONE seam on the controller instance (an own-property shadow over the prototype method; restored only-while-ours on dispose). The wrapper parses the dsh-resource://file/session/<id>/<rel> / …/absolute/<path> address, resolves the session workspace root, and translates the open into openTab('vscode', { params: { path, line? } }) — the column expands and the ONE workbench tab is revealed, the file landing inside it through tab.navigation (a monotonic revision with the params: the one-shot command vehicle). File-type blocklist (openBlocklist): a file whose extension is on the list (by default pdf/docx/xlsx/pptx/png/jpeg/jpg; editable in the settings card) is NOT taken over — the call falls through UNTOUCHED to the stock routing, where the official registry's viewers claim it (today the builtin text preview; any future viewer plugin registers its own type). Declines behave identically: switch off, a non-file address, an unparseable one, or an unknown session root all fall through, and the settings are read per click. After the claim: the body's navigation consumer → the extension spool (/tmp/dsh-sidebar-vscode/<slug(workspace)>/cmd.json, polled every 500ms, showTextDocument) — a cap.json liveness marker plus a capability probe gate the channel, and any miss degrades to a one-shot URL-payload workbench reload. Boot-tagged delivery (extension ≥ 0.1.3): the command is deferred until this boot's nonce is parked and tagged with it (cmd.json {boot}); only the extension host that activated with the same nonce consumes a tagged command (a lingering previous host must not eat it into its dying window). Switch off = the feature is entirely disabled (chat behavior unchanged).

  • Settings "open configuration file" takeover (same switch): the settings page's「打开配置文件」button stock-behavior hands $DSH_HOME/settings.yaml to the Host OS opener — dead on headless containers (xdg-open missing); on the current runtime the click drives the remote.settings.openSettingsDocument Host Remote (SettingsDocumentStore.open is its only production caller; the wrapper redefines the namespace method's getter-only own property, installed through a nested optional inject that parks until the remote.settings service exists), and on pre-gateway runtimes the legacy /api/settings.openDocument member — exactly one of the two ever intercepts. With the switch on, this plugin instead resolves the document path through its own fenced node-half route (POST /sidebar-vscode/api/settings.document → prepareDocument()), then reroutes it as openTab('vscode', { params: { path } }) — the same navigation channel as the chat clicks; the file opens inside the embedded VS Code (the absolute path needs no pathMap rule since mapPathForOpen passes unmatched paths through). A landed reroute also closes the settings dialog itself: its open state is component-local (no service exposes a close), so the close rides the panel's own document-level Escape listener — mounted exactly while the dialog is open — via one synthetic Escape keydown, leaving the workbench in view. Fail-soft: an absent settings provider, an un-reloaded host half, or any transport error falls back to the stock open (dialog stays open), so the button never breaks.

Installation

Prerequisites

  • A DSH host (Web GUI) whose web app carries the official right Sidebar (@deepseek-ai/dsh-client-ui-sidebar-right; the sidebarRightTabs / sidebarRight services exist from 0.1.5-alpha.1 and configForms from 0.1.7 — the session header's「展开侧栏」button is the visible tell). Without it the client fiber parks silently and nothing registers;
  • A code serve-web instance reachable from the browser — pick a wiring shape:
    1. Built-in proxy (the default; the choice for gateway-less deployments: Windows, LAN dsh web) — an EMPTY serverUrl defaults to http://127.0.0.1:8000 (a bare local code serve-web — the CLI's own defaults), or paste any full address it prints (base path and ?tkn= token included). The address is pushed via /sidebar-vscode/api/proxy.config and mounted as the same-origin /sidebar/vscode/ on the very port dsh web listens on (transparent HTTP forwarding, WebSocket upgrade pipe, token auto-append) — zero nginx, zero start flags:
      code serve-web            # zero-config: port 8000, root path
      
      Routing self-corrects to the serverBasePath baked in the index HTML, so --server-base-path is optional (non-mount bases get an identity mirror; root upstreams a <quality>-<commit> shim). When the host cannot reach the address yet, the tab falls back to the direct iframe with a notice and switches to the mount automatically once the proxy serves. Tip: run serve-web with --server-base-path /sidebar/vscode to gather every request under the single mount prefix (identity mount, no mirror/shim). Pre-configure without settings via DSH_SIDEBAR_VSCODE_UPSTREAM (full URLs; default http://127.0.0.1:8000; off disables). If /sidebar/vscode is owned by another plugin the feature backs off with a warning;
    2. Gateway same-origin subpath (the reference topology) — serve-web runs inside the dsh-runtime container behind the gateway's /vscode reverse proxy (see deployment topology); same-host deployments may leave serverUrl empty (the built-in proxy takes over). Only when serve-web is NOT reachable from the DSH host (a split gateway) configure it explicitly (serverUrl = /vscode, or the env var pointing at the real address);
    3. Cross-origin direct URL (automatic fallback shape) — appears only when the host half cannot proxy: the clipboard signal bridge is off (paste fallback keeps working), selections degrade to copy → paste;
  • The companion VS Code extension dsh.selection-reference installed into that serve-web instance (it provides the context-menu commands and the keybinding; the chat file-open channel needs ≥ 0.1.2 — see below).

The plugin itself

Channel A — the bundle channel (standard, recommended for clean profiles)

The package ships a dsh.bundle.patch (cordis.patch.yml: one insert row mounting the host-half entry). From the npm registry (prebuilt — no build permission needed):

dsh plugin --profile web add dsh-sidebar-vscode

From the GitHub repository (source — pnpm runs the prepare build; the repo also carries the committed lib/ artifacts as a fallback):

dsh plugin --profile web add github:chendefine/dsh-sidebar-vscode

Or through the DSH plugin marketplace (设置 → DSH插件市场) — tag the repo with the dsh-plugin topic and it is indexed automatically.

After a bundle plugin is added to the profile layer stack, restart dsh web for it to load; uninstall with dsh plugin --profile web remove dsh-sidebar-vscode and restart again.

Channel B — link + a manual mount row (the hot channel of this deployment, no restart)

# 1. Install the plugin as a link: dependency of the web profile
#    (repo checkout path, e.g. /opt/dsh/plugins/dsh-sidebar-vscode; this
#     deployment's profile dir: /data/dsh-home/profiles/web)
pnpm -C <profile-dir> add link:<repo-checkout>

# 2. Append the mount row to the profile's own cordis.patch.yml
#    - insert:
#        - id: dsh-sidebar-vscode
#          name: dsh-sidebar-vscode

watchUserPatches hot-mounts the node half, the client boot graph recomputes live, and /plugins/dsh-sidebar-vscode/client.js is served immediately — a hard refresh (Cmd/Ctrl+Shift+R) reveals the new tab.

⚠️ The two channels are mutually exclusive: the package's bundle row and the profile's manual row share the entry id dsh-sidebar-vscode — having both fails at startup with a duplicate entry. Remove the other channel's row (and the link dependency) before switching. The in-package double-mount guard (disabled: !!js …) is left commented out by default.

Note: client-half changes apply on a hard refresh; host-half changes (src/index.ts / src/mention.ts) need a dsh web restart (or a hot re-mount of the entry through the profile channel) before the new bundle loads.

The VS Code extension

The send commands and the chat file-click polling channel come from the dsh.selection-reference extension (sources in extension/ — decomposed into extension.js + lib/{protocol,fsutil,envelope,channel,boot}.js along the same seams as the host/client halves), which must be installed into the serve-web instance. The file-open channel needs ≥ 0.1.2 (0.1.1 replays consumed commands on every workbench reboot — see the replay guards below; its capability marker fails the version probe, so open clicks safely degrade to the URL-payload reload):

scripts/install-extension.sh                  # package VSIX → install → register manifest → restart → health-check
scripts/install-extension.sh --skip-build     # reuse the built VSIX
scripts/install-extension.sh --vsix <path>    # use a given VSIX

The local code binary is the standalone CLI (no desktop install), so code --install-extension does not work; the script installs via four steps: package with vsce → drop files → register the extensions.json manifest → restart serve-web with its exact previous argv. Step-by-step details and troubleshooting: scripts/install-extension.md (中文).

Replay guards (≥ 0.1.2) — closing the sidebar's VSCode tab tears the workbench iframe out of the DOM and reopening it boots a fresh extension host; a spool command that survives consumption re-opens its file on every such reboot (the "closed file reopens on next VS Code start" bug). Three independent guards: the extension deletes cmd.json the moment it acts on it (or skips it as garbage/TTL-expired — commands older than 10 minutes are never opened), a consumed-nonce watermark persists in last.json for delete-failed corners, and the versioned cap.json marker ({v:2,at}) makes the client's capability probe refuse the replaying 0.1.1 outright.

Clean embedded boots: ledger + reconcile + hidden reveal (≥ 0.1.2) — the iframe teardown also skips VS Code's unload lifecycle, so its editor-state restore can replay a file the user closed seconds before closing the tab (VS Code flushes workspace editor state only periodically). The model restores exactly what was open at teardown and shows nothing in between:

  • Editor ledger (editors.json) — the extension records the window's open file-tabs (order + active editor) into the spool synchronously on every tab change, so the teardown race cannot lose it; whatever is on disk at the next activation is the previous session's final state.
  • Boot reconcile — at activation the extension waits for VS Code's own restore to settle, then makes the window match the ledger: restored tabs the ledger does not list (files closed before the teardown) are closed — dirty tabs survive, data wins —, ledger files the restore lost are reopened, and the active editor is restored. A boot with no ledger (first ever boot in a workspace, or a degraded URL-payload open) keeps VS Code's own behavior untouched.
  • Hidden reveal (boot.begin / boot.status) — before mounting the iframe the tab parks a boot nonce (bootreq.json); the extension echoes it in its post-reconcile boot.json receipt, and the client keeps the frame at opacity 0 behind the loading overlay until the echo lands. The first visible frame already shows the reconciled editor area — nothing ever visibly opens just to be closed again. The reveal is a race, not a single wait: the receipt poll runs concurrently with the DOM-quiet watcher (same-origin peek at the workbench's editor tab strip) and whichever settles first wins — a reloaded frame's extension host may never re-activate (its receipt then never lands), and a workbench that has painted is proof enough that the staging is done. Quiet alone is not that proof, though: during a ghost boot the strip is quiet-but-wrong for the whole reconcile settle window (quietness is the precondition of the close that follows it, never evidence the close happened), so the racer is ledger-aware — boot.begin answers with the parked boot ledger's desired open-editor set, and the quiet reveal (plus the expired 4s poll budget) additionally requires the sampled strip to match it as a basename multiset (the tab DOM exposes no full paths); a matched receipt still reveals unconditionally, an absent ledger (first-ever boot / older host half) gates on quiet alone, and a strip the ledger mismatches keeps the frame hidden until the reconcile's close lands or the watcher's own 8s ceiling fires. Revealing early has a second hazard the handshake alone cannot see: a file the user opens in the reveal-vs-reconcile window postdates the ledger the reconcile diffs against and would be closed as a ghost — so the first user gesture inside the revealed frame stamps interact.json (nonce-scoped, route boot.interact), and both the reconcile's close loop and the ghost passes stand down for a boot whose user is already interacting (the receipt reports the deferrals; a stale stamp never disarms a later boot). Fail-soft everywhere: on an older host half (boot routes not reloaded yet) the DOM-quiet watcher alone decides, and a cross-origin direct iframe boots ungated with stock behavior.
  • Cross-tab boot lock — two same-origin DSH pages booting the workbench concurrently race to create VS Code's IndexedDB storage (vscode-web-db), and one of them can then hang for minutes inside its own boot (measured: a 294s database open; two fresh profiles stalled until the other tab closed). Before a frame may mount, the tab therefore holds the Web Lock dsh-sidebar-vscode:workbench-boot and releases it once the workbench has painted (the race is over by then); a contended wait past half a second is named in the loading overlay. Fail-open by design: no Web Locks API, a wedged holder (60s wait cap), or an unreadable frame (30s hold cap) all proceed rather than block.
  • Ledger ownership fence + boot rotation (extension ≥ 0.1.3) — serve-web also keeps extension hosts alive for a while after their renderer went away, and such a lingering host keeps its armed ledger handlers: it writes its own (invisible) window's tab set into the shared editors.json, and the reconcile's reopen loop re-opens ledger files into that window, whose tab events then re-write the ledger again — poisoning it with files the visible workbench never showed, which every later boot faithfully restores (closed files coming "back"). The ledger is now owned by the boot: every ledger write, the reconcile itself, and the ghost passes re-check that the parked boot nonce is still the one the host activated with. The client rotates that nonce whenever the frame reloads in place (a workspace / serverUrl / pathMap change, a degraded-channel payload, or the manual reload button — the runtime's re-key path, a fresh renderer whose host would otherwise activate against the previous boot's nonce; a plain tab switch no longer reloads anything, see The persistent workbench) and when the workbench is destroyed, retiring every host that still holds the old nonce.
  • Late-ghost passes (extension ≥ 0.1.3) — VS Code's own restore can still be landing tabs after the reconcile's settle budget ran out (a slow first boot of a heavy workspace), and those late arrivals are exactly the closed-file ghosts nobody else will close. A few close-only diff passes run after the arming, spaced across the restore tail: each closes background tabs the boot ledger does not list, sparing dirty tabs and the ACTIVE editor (seconds after a remount the only deliberate opens are user ones, and a user open becomes the active editor). No pass ever opens anything.

Usage

Opening the tab

Expand the right sidebar and pick the VSCode 工作台 / VSCode workbench entry box on the guide ("开始") page — it opens in the guide tab's place. The layout is in-memory: a page refresh folds every session back to its collapsed default. The toolbar shows the workspace path;「⧉ open in new window」pops a standalone one.

Sending