DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

zengqingsong /

zengqingsong/dsh-sidebar-frog

Verified

可弹出侧边栏 · Popout Sidebar — an artifacts + file-tree sidebar for DeepSeek Harness: offline previews (code / Markdown with math & Mermaid / PDF / HTML / images / CSV tables / Word·Excel·PowerPoint / audio-video), CodeMirror editing with Ctrl+S, a read-only Git slice, and a one-click pop-out into a second-monitor tab.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@75c86559

Popout Sidebar · dsh-sidebar-frog

English · 简体中文

Listed on dsh-plugin.org

sidebar-frog — a pop-out sidebar for DeepSeek Harness

An artifacts + file-tree sidebar for DeepSeek Harness — it keeps the files the agent created or edited, and the current workspace, within reach; one click on a file opens it as a new tab inside the sidebar, previewed at full panel width (code / Markdown with math + Mermaid + JSXGraph / PDF / HTML / images / sortable CSV·TSV tables / Word, Excel and PowerPoint read fully offline / audio & video streamed with HTTP Range). When you want a bigger screen, one click pops it out into its own browser tab that you can drag to a second monitor and read side by side. Sidebar and popout page stay in sync in real time.

  • Repository: https://github.com/zengqingsong/dsh-sidebar-frog · License: MIT · Author: 曾青松 (Zeng Qingsong)
  • Unofficial community plugin — an independent project, not affiliated with or endorsed by DeepSeek. The name, the frog and the panel are this plugin's own.
  • Origin: a fork of e2mcc/dsh-popout-sidebar (MIT · Copyright (c) 2026 Qinyun Cai) — the upstream notice is kept verbatim in LICENSE, and the differences are described under Author
  • Zero runtime dependencies: no npm package beyond React, and no network access (every preview library is bundled)
  • Permissions and risk: it reads and writes only inside the session's workspace; every data route is fenced exactly the way DSH's own /api is, so an unauthenticated call answers 401; and it makes no network requests at all — no external service, no telemetry, no runtime npm dependency, no build script run on install (see How it works).
  • Compatibility: verified on DSH 0.1.5-rc.2, web profile — the only profile with a browser UI. Installing runs no build step (both halves ship committed as src/host.js and src/client.js), so pnpm never asks you to allow a build script; and on a DSH build whose right-sidebar tab registry is absent or has changed, the plugin falls back to its own floating panel instead of failing to load.
  • Shape: inside the system right sidebar it occupies five tabs — Files (this plugin's file tree, taking over the system's own kind)/ Artifacts/ Jobs/ Usage/ Git — with chip, chrome, fullscreen, floating and drag-to-resize all provided by the system; the left column's footer also carries a permanent File treebutton (the system hides its own expand button in a blank session — see Quick start). Where the system exposes no tab registry (an older build, or a changed contract) the plugin falls back to its own floating panel; both paths are covered by the guards.

Table of contents

Highlights what this plugin solves, at a glance
Visible proof two real frames, and how to regenerate them
Quick start install / upgrade / restart
Features popout page · artifact ledger · file tree · preview types · diffs · editing and saving · settings · panel chrome and toolbars · Git read-only slice · the removed built-in browser
Settings 10 preferences and their ranges (plus 5 fixed behaviours with no switch)
How it works host / client / cross-window bridge
Sidebar vs popout the deliberate differences, and what is aligned
Layout what every file is for
Development what npm run check is actually watching
Theming · Updates light/dark, and the build-id self-check
Author · License

✨ Highlights

Feature In one line
🖥️ Popout · two-monitor workflow One click pops the sidebar out into its own tab (/dsh-sidebar-frog) that you can drag to another monitor for a larger, clearer view; both sides show the same content, synced live — session / workspace, settings, and the splitter position are all shared, and the @-reference button on the popout page writes straight back into the main window's composer (the layout differences are listed under known differences)
📦 Works out of the box · fully offline pdf.js, MathJax, Mermaid, JSXGraph and the Office readers (docx-preview + JSZip, SheetJS, @aiden0z/pptx-renderer) are all bundled (no CDN), so PDFs, formulas, diagrams, interactive geometry and Word / Excel / PowerPoint documents render just as well offline or inside an isolated intranet
🌳 What the agent produced is what you see Files the agent writes or edits show up automatically, as do files produced indirectly by bash / pwsh commands (a chart drawn by a script, for instance) — click one to preview it
↔️ Click a file → a new tab in the sidebar The sidebar only owns one strip of the screen, so it no longer squeezes preview and file tree side by side: clicking a file in the tree or the artifact ledger adds a file tab to the panel's tab strip, and the preview takes the whole panel width; the ledger and the tree each stay in their own tab, one click away. Every file tab carries a ✕, and clicking the same file twice just switches to the tab that is already open
🖥️ The popout page is still a two-column layout The standalone tab has room to spare, so it keeps preview on the left and list / file tree on the right, with a draggable splitter whose position is shared with the "popout preview width" setting and remembered (default 80%, preview ≥300px, file tree ≥280px)
🔄 Refresh one directory at a time The ↻ on a hovered directory row re-reads only that level: every other expanded directory, its children, and the scroll position stay exactly where they were. The toolbar ↻ likewise preserves expansion state (Shift+click is the full reload)
⚡ Deeply fitted to the agent workflow An inline @-referenceon a tree row writes @path straight into the session composer (falling back to copying when unavailable); pressing the same button on the popout page writes the reference back into the main window's composer across windows; edited files show their "− removed / + added" diff hunks; in the floating form there is also a permanent toggle in the top-right corner, visible even with no session
🧩 Every view is a tab of the system sidebar Files / Artifacts / Jobs / Usage / Git each own a system tab, and the guide page lists all five entry points: chip, chrome, fullscreen, floating, split and drag-to-resize are all the system's. The "Files" tab takes over the system's own kind at the extension band (the official contract states that a kind may carry a builtin and an extension at once, that the extension is the one in force, and that the builtin comes back when it is disposed), so the system sidebar's Files tab is this plugin's file tree — @-references into the composer, the context menu and per-directory refresh are all there (the built-in tree is a plain list with no context menu and no reference action at all). On the native surface the plugin draws no view switcher of its own and no sidebar toggle either (that is how the duplicate "collapse sidebar" disappeared). A setting hands the whole thing back to the system in one click, and where the system has no registry the plugin falls back to the floating panel (every path is guarded)
🌿 Git read-only slice Current branch + ahead/behind + four lists (changed / staged / untracked / conflicted); click a row to see that file's line-level diff against HEAD right there. Read-only is a hard boundary: every git call goes through the system's own ctx.subprocess (executable resolution, managed process scope, timeout abort), argv is always a literal vector defined in that file, and no verb outside the allow-list exists — no staging, no commit, no checkout, no stash, no discard
✏️ Edit and save without leaving the sidebar (P1-10) Markdown / plain text / CSV open in a CodeMirror 6 editor (vendored, offline, fetched on the first click of 编辑) with Ctrl+S; the save goes through the host's workspace fence + version CAS and puts the file's own CRLF and UTF-8 BOM back, so editing one paragraph never rewrites the whole file; a conflict is a stated refusal with "reload" or "save anyway", never a silent overwrite; and a save lands in the same undo history as the agent's edits, so Undo puts your own change back too
📐 Lending the offline renderers to the system sidebar .md, tables (csv / tsv) and Office documents (docx / xlsx / pptx) are each registered into the official ctx.documentPreviews registry at the extension band (external implementations win over the built-in ones), so the official right sidebar's Markdown preview gains math / Mermaid / JSXGraph too, spreadsheets stop being one endless line of text, and Word / Excel / PowerPoint files can be read in the system sidebar as well; the three switches are independent and hand control back at any time. There is also one capability fallback that has no switch: the system's own PDF renderer calls Map.prototype.getOrInsertComputed (Chromium 145+), and where the browser lacks it the plugin takes over PDFs with its bundled pdf.js 3 — where it has it, nothing is registered. Whether a lend actually took effect is spelled out line by line in "System document previews: lend status" at the top of the settings page. The reverse direction goes through the same registry: for containers this plugin genuinely has no reader for (.doc / .odt / .epub), it offers "open in the system sidebar" when a system renderer claims them
🧭 Coexists with other sidebars In the floating form, when another side card is open the panel yields to its left, so both sidebars are usable and neither covers the other
🎛️ Looks like part of Harness itself The floating panel's toolbar is 38px, its divider .5px, its icon buttons 28px, its underline tabs 13px — all values taken from Harness's own side panels and settings page; on the native surface that chrome is the system's and this plugin draws none of it, see panel chrome and toolbars

Visible proof

Two frames of the popout page, produced by npm run check — the real bundle rendering real previews, against the harness's stub host (a guard cannot sit in your browser session, so the harness serves the data instead):

The popout page rendering a .docx offline through the vendored docx-preview, with the artifact list and file tree on the left

The file tree's context menu on the popout page: expand / copy path / copy relative path / @-reference / refresh this folder / expand all

The sidebar inside the app shows the same content through the same shared preview code; the popout page is what a check can drive with real mouse and keyboard events on this machine. Regenerate both with npm run check (they are written to the system temp directory and printed at the end of the run).


🚀 Quick start

Prerequisite: DSH is installed (dsh web runs).

# 1) keep DSH itself up to date
npm install -g @deepseek-ai/dsh        # npm uninstall -g dsh first if you need a clean reinstall

# 2) install this plugin (static install, persists across restarts)
dsh plugin --profile web add github:zengqingsong/dsh-sidebar-frog

Then restart the DSH service (the host half is loaded once at startup) and hard-refresh the browser (Ctrl/Cmd+Shift+R):

  • The right sidebar gains five tabs: Files— this plugin's file tree, whose rows show an @ reference pill on hover and whose context menu offers expand folder / copy path / copy relative path / @-reference / refresh this directory only / expand all / collapse all; Artifacts— the change ledger; Jobs— background tasks; Usage— context and tokens; Git — branch / ahead-behind / change lists plus line-level diffs, read-only;
  • If the right sidebar is not open yet, its own guide page lists those five entry points — click whichever you want. Expand / collapse, fullscreen, floating, drag-to-resize and splitting are all the system's controls; this plugin does not draw a second sidebar toggle;
    • In a brand-new session, come in through the "File tree" button at the bottom of the left column: the right column's only expand control lives in the session header, and the system hides that entire header (display:none) in a blank session (one that has not run a turn yet) — at that moment the whole column cannot be opened, and that is not something this plugin is missing. This button is present in every state, and one press both expands the column and lands on the file tree (see the right sidebar's default page and entry points); with "expand on load" on, a new session presses it for you;
  • In the file tree, clicking a file makes the system open a document tab in the same column (for .md that is still the renderer this plugin lent out); clicking a file in the Artifacts ledger opens it in the ledger's own document tab, where the line-level diff and Undo live.

If this system's right sidebar has no usable tab registry (an older build, or a changed contract), the plugin falls back automatically to its floating panel: its own entry point then appears in the top-right corner (when closed it is "open sidebar + pop out"; once open, the collapse button sits in the panel's toolbar), the panel switches between artifacts / file tree / Git on its own strip (plus a "Jobs N" view only while background tasks actually exist), and the top of the settings page states which form is in force.

Local development: dsh plugin --profile web add /absolute/path/to/dsh-sidebar-frog (installed as a symlink); after editing src/, run npm run build, restart the service and hard-refresh.

Manual mount: adding "dsh-sidebar-frog": "link:d:/ai/dsh-sidebar-frog" to the profile configuration has the same effect; but do not use dsh plugin add and a hand-written mount line (that double-mounts: two Node halves, two sidebars).


🎯 Features

1) Pop out into a standalone browser tab

  • One click opens the same content in a new tab (/dsh-sidebar-frog) that you can drag to another monitor for a larger, clearer view — handy for checking output while the agent keeps working.
  • Where the entry point is depends on the form in force (all four links share one browsing context, so a second click just switches back to the same tab instead of stacking up a row of them):
    • Native form (default): at the bottom of the left column there are two stacked buttons — File treeon top and Popout pagebelow it (one seat, sidebar.footer.action, present while the column is collapsed and on a blank session's first screen; the stacking is mandatory, see the right sidebar's default page and entry points) — plus "Open in a new tab" in the context menu of every tab this plugin owns; the system's own chip has no visible menu trigger, so that one is the second entry point.
    • Floating form (fallback): the popout icon at the left of the panel's toolbar while the panel is open; while it is closed, another one sits next to the top-right entry point.
    • On the native surface those two floating-panel links are never registered at all (src/client/body.js registers them only when !frogNativeSurface), so these two entry points are an addition, not a duplicate — see the right sidebar's default page and entry points.
  • The standalone page syncs both ways with the main window through cross-tab localStorage: the current session (the file tree follows workspace switches live), the settings ("auto refresh", "file tree", "popout preview width" take effect immediately) and the splitter position are all shared with the sidebar, with no manual step.
  • @-references work from the standalone page too: the inline @-reference button and the "reference into the main window's composer" menu item write @path across windows into the main window's session composer (falling back to the clipboard only when the main window is gone); if you specifically want the clipboard, use "copy @-reference".
  • The standalone page is a two-column layout: preview on the left, "artifact list / file tree" on the right; the splitter starts at the "popout preview width" setting and remembers where you drag it (the sidebar, being narrow, is tab-based instead: one thing at a time).
  • The colour scheme is fixed to light by default (so content stays clear and stable on the other monitor); append ?scheme=dark for dark, or ?scheme=auto to follow the system (already-rendered Mermaid / JSXGraph diagrams do not repaint on a system switch — refresh instead).

2) The artifacts sidebar: a live ledger of what the agent produced

  • Tracks files created or modified by write / edit, tagged "new" or "edited".
  • For opaque executors such as bash / pwsh it compares before/after workspace snapshots and folds files the command produced indirectly (a script's PNG, JSON, report…) into the list.
  • Clicking a row opens a tab inside the ledger: in the Artifacts ledger a document tab appears on that ledger's strip and the preview takes the whole panel width — that is where the line-level diff and Undo live, which is why it stays in the ledger's own tab. A sidebar is narrow, and putting preview and list side by side makes both unusable, so here it is always "one thing at a time". Clicks in the file tree go elsewhere (the native form hands the file to the system, the floating form opens the panel's own file tab — see the next section).
  • Tabs can be closed: every file tab carries a ✕; closing the current one falls back to the most recently opened file tab, or to Artifacts if there is none. Clicking the same file again just switches to the open tab rather than opening a second copy.
  • Hover actions became an overlay: ⧉ (copy path) and @ (reference) float over the right of the row and fade in, so they no longer squeeze the file name; below a certain panel width the @-reference collapses into a compact @.
  • Clear mode only forgets list entries; it never touches files on disk.
  • The ledger is bounded: the list keeps at most the latest 1000 entries (oldest dropped first), and the undo snapshots have three further ceilings — 10 per path, 256KB per snapshot, 32MB overall (see change review / undo).
  • The panel has a readable floor of 280px for file names (see Settings), so long names are never ellipsised away.

3) The file tree: the current workspace, always within reach

  • Both the sidebar and the standalone page have a lazily loaded file tree that follows the current session's workspace and re-roots itself, with no manual refresh.
  • Click a file to open it: on the native surface the click hands the file to the system (synthesising dsh-resource://file/session/<session id>/<path> for sidebarRight.openResource), which opens a document tab in the same column — for .md still the renderer lent out by this plugin. In the floating form it opens the panel's own file tab (preview at full panel width, see the previous section); on the standalone page the file opens in the preview area on the right (there is room there, so the two-column layout stays).
  • Per-directory refresh that does not disturb the tree: hovering any directory row reveals ↻, which re-reads only that level — other expanded directories, their children and the list's scroll position all stay put; the icon spins while the read is in flight and the row flashes briefly when it finishes. Refreshing a directory that was never expanded also expands it.
  • The toolbar ↻ also re-reads only the workspace root level and keeps expansion state; use Shift+click when you really want to start over.
  • A directory deleted on disk disappears along with its cached level, and a directory recreated later is re-read (no stale cache is reused); a failed refresh keeps the previous content and states the reason below it instead of blanking the level.
  • Hovering a row reveals @ reference: one click writes @path into the current session's composer (falling back to the clipboard when unavailable); the full path can also be copied in one click.
  • A single click expands or collapses; a double click does not undo itself: the browser turns a double click into two clicks plus a dblclick. If both clicks toggled, a folder would "expand and immediately collapse", which looks like nothing happened — so directory rows accept only one click (the second arrives with detail > 1 and is ignored). Files keep "single click previews, double click pins".
  • A failed level can be retried: a failed directory remembers its reason and shows it under the row, but it does not count as loaded — collapsing and expanding again (or pressing ↻) re-requests it, so one transient EPERM or file lock cannot pin it forever.
  • Returning to "File tree" does not reload it (floating form): the panel's tabs merely hide the other pane (visibility + absolute positioning) rather than unmounting and rebuilding it — expanded directories, the contents already read for each level and the scroll position all survive; otherwise every switch back would look like a refresh and lose the place you were at. The tree is also only read the first time its tab is opened (nobody is looking, so the root is not read). On the native surface each view is the system's own tab and is unmounted when you switch away, so there the equivalent is a separate expansion memory (see the next point).
  • The root path and entry paths always use the same spelling: the workspace root is returned after realpath normalisation (a session cwd may be D:/ws while entries are D:\ws\src). When the two spellings disagree, every "is this under that" test fails — the tree still draws, but expansion memory, locating the previewed file and relative paths silently stop working. Both sides use the same src/shared/paths.js for these comparisons (separator-insensitive; Windows-shaped paths compare case-insensitively, while under POSIX case still matters).
  • The toolbar's filter / expand all / collapse all / refresh are always available at any panel width (a narrow panel only tightens icons and gaps rather than hiding buttons).
  • The context menu appears at the pointer: it is portalled to <body> through react-dom, so it uses viewport coordinates. It has to be that way — the panel sets container-type: inline-size (layout containment), which makes the panel itself the containing block for position: fixed; a menu left inside the panel would have its coordinates interpreted in the panel's space, so "where you right-clicked" and "where the menu appeared" would disagree. The menu items are expand folder / copy path / copy relative path / @-reference / refresh this directory only / expand all / collapse all.
  • Keyboard: ↑/↓ move, → expands or enters, ← collapses or leaves, Enter previews, Home/End jump, typing does type-ahead, F5 refreshes the expanded directories.

4) Preview types: an offline rendering toolbox out of the box

Type Capability
Code / text Syntax highlighting + line numbers (prefers DSH's built-in Shiki, so it looks native)
Markdown Headings / tables / task lists / code blocks / relative images and inline SVG; $...$ math (bundled MathJax), mermaid fenced blocks (bundled Mermaid: flowchart / sequence / gantt / class / state / er / gitGraph / mindmap / timeline and every other type), jsxgraph fenced blocks (bundled JSXGraph, for interactive geometry and function plots); embedded HTML tags are rendered visually — an inline whitelist (<br> <b> <span> <u> <mark> <kbd> … with on* handlers and javascript: and other dangerous attributes stripped automatically) and block containers (<details>/<summary> for folded answers, <div> <figure> <p>, native HTML tables <table>/<tr>/<td> …) are preserved as a whole with their inner Markdown rendered recursively; formulas and diagrams follow the light / dark theme, and a failed render falls back to showing the source
PDF The sidebar renders with the bundled pdf.js (zoom / paging, fully offline, falling back to the browser's built-in viewer if loading fails); the standalone page uses the browser's built-in viewer
HTML Previewed in a sandboxed iframe (allow-scripts)
Images Shown directly, with over-long text truncated (SVG goes this way too: <img> does not run scripts inside an SVG, which is exactly why it is not inlined)
Tables (csv / tsv / psv) A real table: delimiter sniffing (comma / tab / semicolon / pipe, scored by how consistent the field count is per row), RFC 4180 quoting (cells containing the delimiter or a newline, "" escapes), a sticky header, click-to-sort columns (a third click restores the file's original order), right-aligned numeric columns with tabular figures, empty values last (ascending and descending alike — flipping that is the classic table annoyance), row and column counts plus the delimiter in the status line, and an explicit "showing the first 500 rows" past 500
Audio / video (mp3 / wav / flac / m4a / ogg / aac / opus / mp4 / webm / mov / mkv …) The browser's native <audio> / <video> controls, backed by HTTP Range support on the host's /media route (206 + Content-Range, 416 past the end, Accept-Ranges, individual responses capped at 8MB so a 2GB video is never read into memory) — that is what makes the scrubber draggable; when the engine cannot decode a codec it says so, rather than leaving a black box that will not play
Office documents (docx / xlsx / pptx) Read offline: each of the three containers is parsed by a library bundled with this plugin — Word through docx-preview + JSZip, rendered as a paged document (headers, footers, footnotes, tables, embedded images converted to data URLs so the page stays self-contained); Excel through SheetJS, rendered as a real table (values as Excel displays them, merged cells, worksheet switching, a sticky header, and an explicit truncation notice past 4000 rows × 200 columns); PowerPoint through @aiden0z/pptx-renderer, rendered as slides you can page through. Fully offline: the libraries are this plugin's own static assets (/dsh-sidebar-frog/office/*) and the file never leaves the machine. A file over 24 MB, or a ZIP declaring more than 192 MB of expanded content (a zip bomb), is refused with the actual size stated; encrypted, non-ZIP and corrupt packages all report a clear error and never leave a blank pane
Binary documents (doc / xls / ppt / odt / ods / rtf / epub …) Not parsed: this plugin has no reliable reader for these containers, and decoding them as text only yields a screenful of mojibake. What is shown is an explanatory card — "open in the system sidebar" when a system renderer claims the type, plus "view as plain text" as an explicit escape hatch (bytes are only requested from the host once you press it, see "the reverse holds too" below)

Using jsxgraph blocks: wrap JS in a ```jsxgraph fence inside Markdown; when the code runs, JXG (the library object), id (the current board container's id), BOARDID (an alias of id) and container (the container element) are all in scope. You can either build a board with JXG.JSXGraph.initBoard(id, ...) or use the JXG.JSXGraph.initBoard(BOARDID, ...) form that Orange courseware and the VS Code jsxgraph plugin use (a failed render shows the concrete error plus the source), for example:

```jsxgraph
const board = JXG.JSXGraph.initBoard(id, { boundingbox: [-5, 5, 5, -5] });
board.create('point', [1, 2], { name: 'P' });
container.style.height = '320px';
```

A board fills the preview width by default with a 280px minimum height; the library and its stylesheet are bundled offline assets, lazily loaded the first time one is needed.

These renderers are lent to the system's own sidebar as well — four registrations in total: three that follow their own switches (Markdown / tables / Office) plus one capability fallback that has no switch (PDF, see below). .md / .markdown are registered into the public ctx.documentPreviews registry as extension band document renderers (the official documentation's own words are "External implementations win over product implementations"), so the official right sidebar's Markdown preview gains math / Mermaid / JSXGraph too — one implementation, two places it takes effect, no second UI. The body reuses the same MarkdownView (not a copy, so the two cannot drift), and the registration lands on the keyed seat the product itself declares, sidebar.right.tab.document, with a key equal to the renderer id.

csv / tsv use the same registry to lend out the table renderer. The product's own registry only has md / markdown, html / htm, pdf and a plain-text fallback — no table renderer at all — so this one is pure gain: without the lend, a table in the official sidebar is one endless line of text. The body likewise reuses the panel's TableView (the same src/shared/table.js parser), so the two windows cannot disagree about what counts as a cell.

docx / xlsx / pptx go through the same registry to lend out an Office reader, but they are fetched differently: it is the only switch-controlled registration with loading: 'bytes-complete' (the PDF fallback uses that mode too) — an Office file is a ZIP container, and the system's default text-pages delivery only hands over page after page of decoded text, which is useless to a parser, so the renderer asks for the whole file's bytes (the system's document owner reads them in full and hands them over). No product renderer claims these three extensions either, so this is pure gain as well: without the lend the official sidebar only says "there is no available way to view this kind of content". The body reuses the same OfficeView / src/shared/office.js implementation and the same teardown as the panel and the popout page.

The PDF one is a capability fallback, not a preference, so it has no switch: the product's own PDF renderer uses pdf.js 6, which calls Map.prototype.getOrInsertComputed / getOrInsert — methods that only reached Baseline in February 2026 (Chromium 145 / Firefox 147 / Safari 26.4) — and it cannot be patched: the product builds pdf.js's Worker from a Blob it creates itself, so a missing method throws in a realm this plugin cannot reach and the system tab can only show one line of error. So this plugin probes the capability before lending anything: when the browser lacks those two Map methods, .pdf is taken over at the extension band and drawn by the same PdfView the panel and popout page use (bundled pdf.js 3.11.174); when the browser has them, not one registration is made and .pdf stays with the system's newer renderer. The probe runs on every lend sync, so a browser upgrade hands control back without a refresh.

Whether to lend is three independent settings ("render system Markdown with this plugin" / "render system tables with this plugin" / "render system Office documents with this plugin"). The Markdown one is a trade-off rather than pure gain: the built-in renderer has its own chrome (code copy buttons, footnotes) and what this plugin brings instead is formulas and diagrams; the table and Office ones have no competitor (which is also why the switches only affect the system sidebar — this plugin's own panel and popout page render Office regardless). Turning one off withdraws that registration and the system renderer wins again immediately — scripts/check.js drives "after switching off, the system renderer really does win back", "a live withdrawal / restore through the cross-window settings channel, each happening exactly once", "the three switches do not drag each other along" (turning Office off must not withdraw Markdown or tables), and both directions of the PDF fallback (when the Map methods are missing it must take over and must take over .pdf only; when they are present it must not register at all).

Whether a lend actually took effect is visible: the top of the settings page carries a "System document previews: lend status" block listing four extension groups (md / markdown, csv / tsv, docx / xlsx / pptx, pdf) and their current state — lent / switched off · handed back to the system built-in / not taken over · the system renderer is available in this browser (the PDF case) / not in force · the registration has not completed yet / not in force · rejected by the system (with the reason) / not in force · this page has no document-preview registry. This is not decoration: a lent renderer can vanish for five different reasons and inside the panel they all look identical (the file simply renders the system's way), so writing the state down is what separates "the system never received our renderer" from "it did, and this file is the problem".

The reverse holds too: this panel asks the registry. When this panel genuinely has no reader for an extension (.doc / .odt / .epub and friends) and an installed system renderer claims it, the binary-document card names which renderer and offers an "open in the system sidebar" button — handing the file to the system as a dsh-resource:// address so its renderer displays it, instead of showing the user a screenful of mojibake. Conversely, the host never sends a binary file as text either: for images / PDF / audio / video / Office documents, and for document containers such as .doc / .xls / .odt, readFile returns empty content outright (while still reporting the type honestly), so the panel can never receive mojibake by default; "view as plain text" is the only path that asks for bytes, by adding text=1 explicitly.

Known limitation: for Markdown in the system sidebar, whether relative images resolve depends on the form of the path in the address (a session-scoped address is sometimes relative, while the media route resolves absolute paths); formulas, diagrams and JSXGraph are unaffected.

5) Edit diffs

For a file an edit modified, the top of the preview shows the "− removed / + added" hunks directly — you can see what changed without re-reading the whole file.

6) Settings panel & theming

  • DSH's settings gain a "Popout Sidebar" tab; every preference lives in localStorage and survives a refresh, and an already-open standalone page follows immediately (the "auto refresh" switch governs the standalone page's polling as well).
  • The sidebar panel follows the main interface's light / dark theme (the --dsw-alias-* variables), so it reads like DSH itself; when another sidebar plugin is open at the same time it yields to its left.

7) Panel chrome and toolbars: matching the Harness system

Every layer of the panel's chrome takes its values from Harness's own panels rather than inventing a set; the more important principle is not drawing what the system already drew:

Part Values Aligned with
Chrome on the native surface At most one strip per view, and not one of them is a view switcher — the switcher is the system's tab bar. There are only two: the ledger strip (the "N artifacts" label at the left, the open document tabs in the middle, "Clear" at the right) and the Git strip (branch + ahead/behind + change summary + last read time + refresh); the file tree / jobs / usage draw no strip at all The system's own file tab has just one 38px strip too (path + tools on one line). The panel no longer draws a second toolbar of its own — above that strip sits the system's tab bar, which is this sidebar's real header
The floating form's toolbar 38px tall, a .5px bottom edge (border-l3), 6px of padding on each side The header of Harness's own side panels (files / document preview)
Toolbar buttons 28×28px, 6px radius, a label-tertiary icon (hovering turns it label-primary + interactive-bg-hover) The icon buttons in Harness's panel headers
The clear-mode button A small secondary button with a 1px border-l2 outline, an 8px radius and 12px text; on the native surface it is position: sticky at the right end of the ledger's strip (in the floating form, at the right end of its own toolbar), so any number of file tabs cannot scroll it away The settings page's secondary buttons
Tabs (floating form: artifacts / file tree / Git, plus a "Jobs N" that only appears while background tasks actually exist; the native ledger: one per open document) 13px / 20px text, label-tertiary, the selected one label-primary with a 2px underline, a .5px baseline (border-l2), 16px gaps; file tabs add a ✕, and the strip scrolls horizontally instead of wrapping The settings page's tab strip
Transient feedback ("path copied", "inserted into the composer" …) No longer occupies any strip of the panel: it renders into the system's own fullscreen overlay shell.overlay (a pill at the bottom centre). That seat's contract is literally to hold the toast stack there, and the whole layer is click-through — the pill itself takes no pointer events either The system's own framework-level overlay

The panel's form: every view is a tab of the system right sidebar. The system's tab registry lets one kind carry a builtin and an extension at the same time, and the extension is the one in force, with the builtin restored automatically when the extension is disposed — so the {id:'dsh-sidebar-frog/files', kind:'files', priority:'extension', title:'文件', guide:[…]} this plugin registers displaces the system's own file tree: the system sidebar's "Files" tab is this plugin's file tree, with @-references into the composer, the context menu, per-directory refresh, filtering, keyboard navigation and the A/M change letters all present.

That is not showing off, it is required: the built-in file tree is only a plain list (in its bundle, contextmenu and reference actions appear zero times). The moment this plugin's tree gives way to the system's, the user sees "the file tree lost its @-references and its context menu" — the position changed hands.

The five views each own one tab:

View kind Notes
Files files (displacing the system's own kind) This plugin's file tree; clicking a file hands it to the system, which opens a document tab in the same column
Artifacts frog-artifacts The ledger of files the agent created / edited, with line-level diffs and undo
Jobs frog-jobs Background tasks in the session (a read-only mirror of the system's jobsBySession)
Usage frog-usage Context occupancy and cumulative tokens (read from the system's session projection)
Git frog-git The read-only Git slice (branch / ahead-behind / change lists / diffs against HEAD)

So the chip, the chrome, fullscreen / floating / splitting / the tab menu are all the system's — on the native surface this plugin draws no view switcher of its own: each native view has at most one strip belonging to it (the ledger's holds the open document tabs and "Clear", Git's holds branch, ahead/behind, the change summary and refresh), and the file tree / jobs / usage draw not even one.

The guide page: one entry per view, which is why the right column now defaults to the guide page. The system's default-tab rule is "if the guide page has exactly one entry, open that entry; with two or more, open the guide page". Making every view reachable from the guide page costs exactly that default moving from the file tree to the guide page (which lists five entry points) — a deliberately chosen trade-off: "where do I find a view" should not depend on which view happens to be open. The plugin therefore adds the "session's default page" itself (see §5), so a blank session does not sit on a table of contents.

Both registration steps follow the official pattern: the type goes into the registry (no patterns means it is a page type), and the body goes into the keyed seat through slots.inject('sidebar.right.pane.tab', …) (the seat looks the body up by entryKey = the definition's id, so the body's key must be that id and not the kind; the inject is required — this seat is a child of rightbar.session and only exists once the session view has mounted). A failed registration (an id already taken, a changed contract) rolls back every type it registered and falls back to the floating panel, never leaving a type without a body.

⚠️ Changing a kind, or retiring a view, leaves you a batch of "orphan tabs": the right sidebar's layout is persistent (it travels with the session log), so tabs that were open before an upgrade are restored verbatim afterwards. The seat dispatches a body by definition?.id ?? tab.kind — once the old kind's definition is gone it falls back to the bare kind, and if that is not found either it renders the system's "there is no available way to view this kind of content.". So this plugin keeps a body for every kind it has ever used (FROG_LEGACY_TAB_KEYS), and a legacy-kind tab draws the view it used to be (the artifact ledger) rather than the new tab's default file tree. The rule: from here on, changing a kind or an id (or deleting a view) may only append to that list, never replace it.

It can be switched off too: with "carry the panel in the system right sidebar" off in settings, the plugin does not register these kinds (the system's own file tree comes back as it was) and the panel falls back to its floating window; the page refresh applies it.

Four actions, whose position follows the form, with no overlapping meanings:

Action Native form (default) Floating form (fallback)
Expand / collapse The system's own sidebar toggle (the plugin draws none) While the panel is open, the collapse button inside the panel's own toolbar handles it; while it is closed, the top-right entry point only opens — it no longer offers "collapse sidebar", which is precisely the duplicate control that was deleted from the session title bar's shared button area
Pop out into a standalone tab Two entry points: the permanent Popout page button directly below "File tree" at the bottom of the left column (a single click; present while the column is collapsed and on a blank session's first screen), and "Open in a new tab" in the context menu of every tab this plugin owns. The system's floating / fullscreen solve "see it bigger inside the same column", which is a different problem from viewing it on another monitor, so the native form no longer omits this The popout icon at the left of the panel's toolbar; while the panel is closed, another one sits next to the top-right entry point
Clear the artifact records The right end of the ledger's strip (it acts on the ledger, after all) The right end of the panel's toolbar
Open a file Handed to the system: a click in the file tree gives a dsh-resource:// address to sidebarRight.openResource, and the system opens a document tab in the same column (for .md, still the renderer this plugin lent out); the artifact ledger still opens files in its own document tab (where the diff and Undo are) The panel's own file tabs (the floating panel has no reliable access to the system's strip)

The top-right entry point's position is measured, not computed: the shell keeps a sidebar toggle of its own at the top-right of the session title bar, and the floating panel happens to narrow exactly that column, so any fixed offset would land the two icons in the same 28px band on top of each other — so the entry point measures the system button's position first and tucks itself 8px to its left (falling back to the stylesheet's fixed offset when the system has no such button) — see CornerButton in src/client/components.js.

Yielding is measured, not guessed: the system right sidebar (native tabs such as files and document preview) exposes no width variable; the --dsh-sidebar-width of history belonged to the community better-sidebar, and when it handed the right column back to the system in v0.19, no plugin has written that variable since — computing an offset from it yields 0 every time, and "yielding" silently degrades into "not yielding". So the plugin instead measures the system panel element directly ([data-sidebar-right-panel], with a ResizeObserver, attribute observation and a fallback poll) and publishes its own --dsh-sidebar-frog-right, which the framework yield, the panel geometry and the drag clamps all read (watchShellRight in src/client/core.js; the old --dsh-sidebar-width is kept only as a fallback).

Change review / undo: once an artifact row is open, the top of the preview is a line-level red/green diff (line numbers plus a +n −m count, rendered by the same code in the panel and the popout page, coming from src/shared/linediff.js), with an Undo button on the right that restores that change. What makes undo possible is a host-side snapshot: the complete before/after text of a write / edit only exists in the live tool execution result (ToolExecutionSuccess.value, whose documentation says outright that it is "deliberately omitted from durable events" — the session log only keeps a hunk-level diff), so the host takes it inside the tools/execute wrapper and keeps a bounded history (10 per path, 256KB per snapshot, 32MB overall, oldest evicted first). Writing back uses the file version as CAS: if the file changed again after the edit, the undo is refused with the reason stated and nothing is overwritten. A newly created file cannot be undone (ctx.fs has no delete interface), and the interface marks it "not undoable" rather than pretending it succeeded.

The background-jobs tab: the session's background tasks come from the system's own mirror (the session list store's jobsBySession, the very same array the title bar's task counter reads); the panel presents them read-only (state dot, type, label, running time, detail) and offers no start / stop — that lifecycle belongs to the system. With no tasks, this tab does not exist at all.

The usage tab (a system tab, not one we drew): context occupancy, context breakdown and cumulative token usage all come from the system's own session projection — the host is the only calculator, and the client reads finished values by key (key → {value, seq}, the higher seq winning). The tab body reads the contextPressure / contextBreakdown / tokenUsage keys from the useProjection the seat injects by default, so it adds no host code, no route and no ledger, and cannot disagree with the occupancy ring next to the composer: the percentage uses the product's own rule (projectedTokens ?? pressureTokens divided by contextWindow, capped at 100%), and the three colours of the breakdown bar are the product's own three values.

It is a separately registered system page type (frog-usage in ctx.sidebarRightTabs, extension band), so its chip, chrome and fullscreen / floating controls are the system's — it is not another tab strip drawn inside our panel. Like the other views it carries its own guide-page entry, so "where do I open usage" does not depend on which view is currently open; and this plugin puts no second "usage" button on any strip — that would be a second entry point for the same thing. Its kind and id fall under the same FROG_LEGACY_TAB_KEYS discipline (renames may only append).

The Git tab (read-only slice, P1-9): branch, ahead/behind, upstream, and the change list; click a row to see that file's line-level diff against HEAD right there (the ledger's own renderDiff reused, only with a title that states the comparison instead of calling it an "edit diff"). The list is grouped by git's own semantics into conflicted / staged / unstaged / untracked, and a file that is staged and then modified again (MM) appears in both groups — that matches git's own status, whereas merging them into one row would hide that part of it is already in the index.

The confinement to "read-only" is deliberate: there is no staging / commit / checkout / stash / clean / discard, because that is the one operation through which this plugin could silently destroy the user's work, and the genuine need behind it ("put a file back") is already served by the artifact ledger's Undo (with version CAS). The implementation goes entirely through the system's own ctx.subprocess (resolveExecutable('git') → managed process scope → a 6-second AbortController deadline), argv is always a literal vector written in this file, never a concatenated string, never through a shell; one guard extracts every gitRun(cwd, [...]) verb from the bundle and nothing outside the allow-list (rev-parse / status / rev-list / config / show) gets through, and every call site must pass a literal vector (a count mismatch is an error). Parsing uses --porcelain=v1 -z: porcelain is a stable machine format and -z separates on NUL, so spaces / newlines / non-ASCII in paths need no unescaping; a rename is the one record that is not self-contained (the original path follows as the next NUL record), and swallowing it as a separate entry is the classic bug here, so it has a case of its own. Not a repository, git not installed, a bare repository and an unusable workspace each state their own reason rather than drawing a lying "working tree clean".

8) Git read-only slice

The current branch, ahead/behind, the upstream (its remote URL redacted), and the change list grouped by git's own semantics (conflicted / staged / unstaged / untracked). Click a row to see that file's line-level diff against HEAD right there. Read-only: none of staging / commit / checkout / stash / clean / discard exists; every git call goes through the system's own ctx.subprocess, argv is a literal vector, and the verb allow-list is pinned by the guards.

9) Built-in browser: removed (why, and what happened to it)

It used to be an address bar plus a sandboxed iframe: preview a local dev server on a port (5173), or preview a workspace page from ./dist/index.html (relative assets served by the plugin's own read-only /site route).

Why it was deleted (withdrawn after review, not broken):

  • The workspace-HTML half was a duplicate: the system's own document preview already renders .html / .htm live, packaging the HTML and its relative assets into a Blob inside a sandbox="allow-scripts" opaque iframe. Clicking an .html in the file tree takes that path, and this view only did it a second time;
  • Only the narrow "needs a real HTTP origin" strip was left: a dev server (HMR, absolute paths, runtime fetch, WebSocket, Service Worker, the page's own login state), and the rare workspace page that only works over HTTP. Real sites almost all refuse to be framed via X-Frame-Options / frame-ancestors, so "surf any website" was never on the table anyway;
  • It bought nothing else: no script injection, no reading the embedded page's DOM (cross-origin plus an opaque origin, on purpose), no console / network panel / back-forward / history, and no way to screenshot the page for the agent to read — it was not a browser, just a pane of glass showing a local HTTP page;
  • And its price was an unauthenticated data route: a frame's requests are cross-site, SameSite cookies do not ride along, and /site had to rely on a per-session capability token (which then had to live in a path segment, or every relative sub-resource would drop it) — the only data entry point in the whole plugin that does not check the browser cookie, and an authentication surface that would need permanent guarding.

What replaced it: to look at generated HTML, hand it to the system's own document preview (click it in the file tree); to look at a dev server, use a real browser. What was deleted: src/client/browser.js, src/shared/siteurl.js, the host-side /site and /site-token routes with their token machinery, and the "browser" chip on the floating strip. A browser tab already open in an old session still comes back — it is in FROG_LEGACY_TAB_KEYS and is drawn as the ledger by the existing rule (otherwise the system would render "there is no available way to view this kind of content." in that position).

The guards pin this removal down as well: view entry points asserts the floating strip no longer offers "browser", and browser view removed asserts the host bundle contains neither /dsh-sidebar-frog/site nor siteToken / readSiteFile / peerIsLoopback, so bringing it back has to be a deliberate decision that carries the original security guards with it.

10) Editing and saving a file in the panel (P1-10)

Open a Markdown, plain-text or CSV file and the preview's bar grows one 编辑 button. It swaps the rendered document for a CodeMirror 6 editor — the same editor in the panel and on the popout page, because both mount the one implementation in src/shared/editor.js. Ctrl+S saves; 预览 goes back to the rendered document (the panel's Esc does the same while there is nothing unsaved). The editor is vendored and offline (604 KB, MIT, the build and its 19 package versions recorded in src/vendor/codemirror/README.md) and is fetched the first time somebody clicks 编辑 — the same lazy-asset contract as pdf.js, MathJax, Mermaid and the Office readers, so the plugin still has zero npm dependencies at runtime.

The host owns the three things that make a save safe, and each of them is a fact rather than a convention:

  • A workspace fence. Reads may resolve any path the filesystem service accepts; a write may not. POST /dsh-sidebar-frog/save resolves the path and refuses it unless the canonical result is under the session's workspace (pathUnder, the same helper the Git view uses). This is the only operation in the plugin a browser can trigger that changes the workspace, so it is fenced like one.
  • Version CAS. The editor is opened on a revision; that revision goes back with the text and the write is guarded by replaceIfVersion, so a save can never quietly overwrite what the agent (or another window) wrote in between. A mismatch is refused with a reason: the pane shows a conflict bar offering 载入磁盘版本 (discarding your text, which is why the button asks twice) or 仍然保存 — and even that forced write is still atomic and still guarded, because the guard is the revision read moments earlier.
  • Byte fidelity. Two things a browser cannot preserve are put back by the host: the file's line-ending style (CodeMirror normalizes a document to LF — and an HTML <textarea> does the same) and a UTF-8 BOM (readText decodes it away). Without the first, saving one paragraph of a CRLF file would rewrite every line of it; the file's mode is preserved by the write service itself.

A save is recorded in the artifact ledger's own history, marked by: 'user' — which is what lets Undo put your own save back, walking the file's real revisions (newest first) rather than pretending the ledger only ever recorded the agent.

What is deliberately not editable. A file whose read came back truncated (the preview carries the first 200000 characters) is never offered for editing — the editor would hold a prefix, and saving it back would shorten the file; both faces withhold the button and the host refuses such a save independently. Images, PDFs, media, Office documents and binary containers have no text to write and show no toolbar at all. New / renamed / deleted files are not part of this either: saving writes an existing regular file and nothing else, so the panel cannot create anything by accident.

Unsaved text is kept per file, outside the component (a file tab unmounts whenever you switch to another one, and the 2 s ledger poll re-reads the preview), so clicking another tab cannot eat what you typed. A dirty file tab carries a ●, and closing one asks twice. On the popout page the drafts live in that page, so the browser's own "leave site?" prompt covers the reload case.

The guards drive all of it: the save route is on the DATA_ROUTES list, so the unauthenticated sweep calls it and expects 401; a dedicated group drives CAS/force/fence/CRLF+BOM/ledger/405/400/oversize through the real route against a stub filesystem; a rendered check asserts 编辑 is offered for a plain Markdown read (with the revision it read) and withheld for a truncated one, an image and every other non-text type; and the real-engine suite types into the actual editor and asserts the actual POST. That last one earned its keep immediately — it caught the popout mounting CodeMirror outside the element that carries the height, which no source guard could see.


Settings

Setting Default Notes
Expand on load on Floating form: the floating panel is expanded after the page loads; off means it starts collapsed. Native form (where it now lives): while on, a brand-new session whose right column is still empty has its "Files" page opened for it — which both expands the column and pins the landing spot to the file tree instead of the guide page (the system's default landing spot is the guide page, because this plugin contributes five entries); sessions that have content, or that the user has already used, are never touched
Auto refresh on While the panel is open, poll for the latest artifact list every 2s; the standalone popout page obeys this switch too (with it off, the page still refreshes once when you switch back to that tab)
File tree on Floating form only: show the "file tree" view on the panel's strip, for browsing the workspace. The native form does not offer this item at all — the system's "Files" tab is this plugin's file tree and must always draw the tree; a native view that still honoured this switch would turn that tab into a blank pane, so the native form neither offers nor honours it
Carry the panel in the system right sidebar on Register every view as a tab of the system right sidebar (files displaces the built-in implementation at the extension band; the other four are this plugin's own kinds): that file tree is this plugin's file tree, with @-references into the composer, a context menu, per-directory refresh and the rest; artifacts / jobs / usage / Git each own a tab, while expanding / collapsing / fullscreen / drag-to-resize / splitting are the system's. The left column's footer also gains a permanent File tree entry point. Switching it off hands all five kinds back to the system (the built-in file tree returns as it was) and the panel falls back to this plugin's floating window. Takes effect on page refresh
Render system Markdown with this plugin on Register this plugin's Markdown renderer with the system's own document previews (ctx.documentPreviews, extension band): .md then renders math / Mermaid / JSXGraph in the system right sidebar too. Switching it off hands control back to the built-in renderer (keeping its code copy buttons, footnotes and other native features). Takes effect immediately, no page refresh
Render system tables with this plugin on Register this plugin's table renderer with the same registry (csv / tsv, extension band): those files become sortable real tables in the system right sidebar too. The product has no table renderer of its own, so there is no trade-off here; switching it off hands them back to the built-in plain-text view. Takes effect immediately, and the two switches do not drag each other along
Render system Office documents with this plugin on Register this plugin's Office reader with the same registry (docx / xlsx / pptx, extension band, the only one of the three with loading: 'bytes-complete'): those three file types become readable in the system right sidebar too — paged Word documents, real Excel tables, pageable PowerPoint decks. The product has no renderer for them either, so this is pure gain; switching it off hands them back to the system (which then says there is no available way to view them). It only affects the system sidebar: this plugin's own panel and popout page render Office either way. Takes effect immediately, and it does not drag the two above along with it
PDF capability fallback — No switch, because this is not a preference but a capability fallback: the product's own PDF renderer (pdf.js 6) calls Map.prototype.getOrInsertComputed / getOrInsert (available from Chromium 145 / Firefox 147 / Safari 26.4), and without them the system tab can only show one line of error — so this plugin takes over .pdf and draws it with the bundled pdf.js 3; when the browser has those methods, nothing is registered at all and .pdf stays with the system. The probe runs on every lend sync, so a browser upgrade hands control back without a refresh
System document previews: lend status — No switch: it is a read-only status block at the top of the settings page, listing md / markdown, csv / tsv, docx / xlsx / pptx and pdf and who renders each group right now (lent / switched off · handed back to the system built-in / not taken over · the system renderer is available in this browser / not in force · the registration has not completed yet / not in force · rejected by the system with the reason / not in force · this page has no registry), and stating which PDF case this browser is in. A lent renderer can vanish for five reasons and inside the panel they all look identical — this block is what tells them apart
Background jobs — No switch: while the session has background tasks they are rendered from the system's jobsBySession mirror (read-only). On the native surface this is a standalone "Jobs" tab, in the floating form it is a "Jobs N" view on the strip (with no tasks, that chip does not exist at all)
Media and tables — No switch: the /media route always supports HTTP Range (which is what makes the audio/video scrubber draggable), CSV / TSV are always rendered as tables, Office documents are always rendered offline, and every other binary container always gets the explanatory card instead of mojibake
Usage — No switch: it is itself a tab of the system right sidebar (frog-usage, with its own entry point on the guide page), and its content comes from the same source and the same formula as the occupancy ring beside the composer. This plugin puts no second "usage" button on any strip — one entry point per thing
Default panel width 26% Floating form only: the panel's width when expanded (a percentage of the window, 20–85%). The panel now holds one thing at a time (a list / the file tree / one file tab), so the default came down from 65% to 26%; after you drag the panel's left edge, this session follows the dragged value and a reload returns to this width. On the native surface the width is the system sidebar's, so the item is not shown
Minimum panel width 20% Floating form only: the panel's minimum width (a percentage of the window, 20–60%). The effective width also keeps file names readable automatically (list / file tree ≥280px, and never more than 45% of the window), so long file names are never ellipsised at any window size
Popout preview width 80% The preview area's share of the split on the popout tab (20–80, defaulting to the upper bound of 80), which sets where the divider between preview and list / file tree starts; you can still drag the divider for a temporary adjustment (it is subject to the same two-column minimums). It does not apply to the sidebar itself — a file tab is already full width

The top of the settings page states whether the current form is native tabs or the floating panel, and the "lend status" block appears in both forms; the three items "file tree / default panel width / minimum panel width" only appear in the floating form (on the native surface the width is the system's business and each view already owns a system tab, so listing them would only offer switches that do nothing — or one that can blank the "Files" tab); "expand on load" appears in both forms — in the floating form it is the panel's own open state, and on the native surface it is the default page (see the right sidebar's default page and entry points).

Popping out is a permanent behaviour in both forms and needs no switch: on the native surface it is the Popout page button below "File tree" at the bottom of the left column plus "Open in a new tab" in the context menu of every tab this plugin owns; in the floating form it is the popout icon in the panel's toolbar (and another one next to the top-right entry point while the panel is closed). In the floating form, "yield to the left of other sidebars" is a permanent behaviour as well.


How it works

Host (the Node process)

  • Listens for tools/result: records successful write / edit calls and their file_path (edit also keeps the old_string / new_string diff hunks), and tags the preview type by extension.
  • Listens for tools/execute: ① it takes the complete before/after text out of the live execution result for write / edit ({operation, before, after, version}) — the only source undo has — and tags "new" or "edited" by the operation the file system reported (an early version tagged every write as new, which once undo existed would delete the user's original file); ② for bash / pwsh it compares workspace snapshot fingerprints before and after (recursive, using version tokens, skipping large directories such as node_modules / .git) to find files the command produced indirectly.
  • Exposes 20 kind: 'exact' routes through webServer.register: the page /dsh-sidebar-frog; the data routes /data /content /media /remove /revert /listdir /search /git /gitfile; the bundled offline assets /pdfjs/pdf.min.js, /pdfjs/pdf.worker.min.js, /mathjax/tex-svg.js, /mermaid/mermaid.min.js, /jsxgraph/jsxgraphcore.js, /jsxgraph/jsxgraph.css; and the four Office readers /office/jszip.min.js, /office/docx-preview.min.js, /office/xlsx.full.min.js, /office/pptx-renderer.es.js (that ES module is the one asset this plugin must serve with a JavaScript MIME type, or the module loader refuses it); it also keeps RPCs through harness.handle (artifacts.list/read/remove/revert/listDir/search) for the dynamic mode.
  • Data routes go through the same gate as /api: webServer itself knows nothing about sessions, and without extra handling routes like /content and /media would hand out workspace files with no credentials at all (measured: 200 without credentials, while DSH's own /api/* answers 401; and if dsh web listens on 0.0.0.0, devices on the same network can read them too). So every data route entry point calls ctx.connection.requestRejection() — reusing the very same Host/Origin trust fence and signed browser-cookie check DSH uses for /api, answering 401 without credentials. The cookie is Path=/; HttpOnly; SameSite=Strict, and both the sidebar and the popout page fetch from an already-authenticated same-origin page, so behaviour is unchanged; the page route and the bundled offline assets (public library code, no workspace information) stay open. When the connection service is unavailable the guard is skipped automatically and the old behaviour returns, so the plugin never becomes unusable.

Client (the browser)

  • Takes over the system right sidebar's files kind by default and registers one system tab per view (src/client/native.js): the five tab types go into the registry (priority:'extension'; the files one beats the built-in implementation, the other four are this plugin's own kinds) and the bodies go into the keyed seat through slots.inject('sidebar.right.pane.tab', …) (key = the definition's id). The system sidebar's "Files" tab therefore renders this plugin's file tree (@-references / context menu / per-directory refresh all present), while expanding, collapsing, fullscreen and drag-to-resize are the system's, and this plugin no longer registers a top-right overlay (that is how the duplicate "collapse sidebar" disappeared); when the setting is off, no registry is detected, or a registration is rejected, it rolls back all five and falls back to the overlay described below. Retired kinds (FROG_LEGACY_TAB_KEYS: frog from the round-3 rename, frog-browser from the removal of the built-in browser view) keep a body but no type — the right sidebar's layout is persistent, so a renamed or retired tab is restored verbatim in old sessions.
  • Adds the two entry points the system does not give us (installColumnEntryPoints in the same module): ① two stacked buttons in one seat at the bottom of the left column (sidebar.footer.action, root scope, present in every state) — a permanent File tree on top and a permanent Popout page below. The system right column has exactly one expand control, it lives in the session header, and that whole header is display:none in a blank session, so a new session cannot open the column at all; that button calls sidebarRight.openTab('files'), doing "expand" and "land on the file tree" in one call. Why one seat with stacked buttons rather than two registrations: the seat is laid out by the system, and dsh-client-ui-sidebar makes it a row in both collapsed states (.footerActions{display:flex}, plus justify-content:center;width:auto when narrow), so two registrations are always side by side; the narrow column is only 56px (SIDEBAR_COLLAPSED) minus 10px of padding on each side = a 36px content box, exactly one round button wide, so the second one is pushed outside the column. The system's own class names are private CSS-module hashes and cannot be changed, so the direction has to come from this plugin: one occupant whose interior is a column (.artifacts-foot-stack — two 36px round buttons stacked when narrow, two 42px full-width rows when expanded, sitting directly above the system's own "Settings" row). ② What "expand on load" means on the native surface: when a blank session's right column has not a single tab, the plugin opens the "Files" page for it — because the system's first expansion landing spot is decided by defaultSeed, whose rule is "the guide page has exactly one entry ⇒ open it; otherwise open the guide page", and this plugin contributes five entries, so the landing spot would necessarily be the guide page. Two rules keep it from ever taking over a column the user has already used: it only looks at sessions the system marks blank, and only when the column holds no tabs at all, at most once per session.
  • Lends the four renderers to the system (src/client/docpreview.js): .md / .markdown and csv / tsv are registered into ctx.documentPreviews as {id, extensions, priority:'extension', loading:'text-pages'}, while docx / xlsx / pptx are registered at the same band but with loading:'bytes-complete' (an Office file is a ZIP container and only the complete bytes are useful to a parser), and the fourth, pdf, is also bytes-complete but behind a capability gate when: it is registered only when the browser cannot run the system's pdf.js 6 (missing Map.prototype.getOrInsertComputed), and when it can, not one registration is made. The extension band beats the official built-ins; the table and Office registrations have no competitor (the official registry only has md / html / pdf plus a plain-text fallback), the Markdown one is a trade-off, and the PDF one is a fallback. Every body goes into the keyed seat through slots.inject('sidebar.right.tab.document', …) — that seat is a child seat declared by the official document previews, and a registration without inject would land nowhere — and each reuses the panel's own view components (MarkdownView / TableView / OfficeView / PdfView). Each of the three switches governs one registration; switching one off withdraws it and the system renderer (or the system's "no available way to view this") returns at once, and each registration's fate is published to the "lend status" block on the settings page so "it did not take" is no longer a riddle. The same module also provides the reverse-direction address tools: parsing dsh-resource://file/… (to recover a path for Markdown's relative links) and synthesising one (to hand a file to the system through sidebarRight.openResource).
  • Transient feedback registers into the system's own overlay (dsh-sidebar-frog-notice in shell.overlay): a pill that renders null while idle, shared by both forms; the panel therefore needs no strip reserved for messages such as "path copied".
  • Falling back to the floating panel, it registers the top-right entry point in shell.overlay (root scope, visible only while the panel is closed, responsible only for opening and popping out) and the floating panel itself, still visible with no session; the floating panel carries its own "collapse" in its own toolbar.
  • Subscribes to the session store and writes the current session to cross-tab localStorage, so the sidebar's and the standalone page's file trees follow workspace switches live.
  • The context menu is portalled to <body> through react-dom (viewport coordinates), and the panel's toolbar / tab strip are drawn with Harness's values.

Right sidebar: the default page and the entry points

On the native surface the whole right column is the system's, and the system's own two defaults happen to make this plugin's tree unreachable in a new session, so the plugin adds two things (installColumnEntryPoints in src/client/native.js):

  1. That seat at the bottom of the left column (sidebar.footer.action, root scope, the only thing this plugin registers into the left column = one occupant holding two stacked buttons: File tree on top, Popout page below). The system right column has exactly one expand control — it is registered at conversation.session.header.corner, and the system sets that entire session header to display:none while the session is still blank (has never run a turn). So in a blank session that column cannot be opened at all: not the system's file tree, and not this plugin's either — that is not a bug in this plugin, it is a fact this plugin has to work around. The "File tree" button calls ctx.sidebarRight.openTab('files'): one call does both "expand" and "land on the Files page" (the system contract's own words: the column expands in the same step, because content the user cannot see is not opened).
  2. What "expand on load" means on the native surface: a session's sidebar surface starts collapsed and with no tabs, and its first page is seeded by the system's defaultSeed, whose rule is "the guide page has exactly one entry ⇒ open it; otherwise open the guide page". The official distribution has only one entry (the built-in files), so the native default lands on the file tree; this plugin contributes five, so the landing spot would necessarily become the guide page — hence, with "expand on load" on, the plugin opens the "Files" page for the user in a blank session (which both expands the column and pins the first page to the file tree).
  3. The popout page's two entry points (the same installColumnEntryPoints): the floating panel's own two links to /dsh-sidebar-frog — the panel toolbar and the top-right entry point — are never registered on the native surface (src/client/body.js registers them only when !frogNativeSurface). So the native form once had no popout entry point at all, while the README kept promising "one-click popout". Two were added: the permanent Popout page button directly below "File tree" at the bottom of the left column (in the same single seat occupant as "File tree" — see the point above for why it must be stacked), and "Open in a new tab" in the context menu of every tab this plugin owns (sidebar.right.tab.menu.item, the official seat the system reserves for a per-tab action menu; it only answers for our own kinds — FROG_TAB_KINDS, including every legacy kind — so no other plugin's tab grows this button). Both are written as <a href target>: a real navigation cannot be eaten by a popup blocker (a blocked window.open returns null, which looks like a dead click), and all four links share one browsing context and addresses assembled by popoutHrefFor() (including sessionId), so they always land in the same tab and the same workspace.

Both rules are deliberately kept tight, because "I opened the sidebar for you" turns into an interruption very easily: it is only considered when the system marks the session blank (blank === true in a sessions.list snapshot, i.e. the ones the system reuses for "new session"); it only acts when that session's column holds no tab at all (sidebarRight.active() === undefined); and it does so at most once per session (once anything appears in the column it retires for good). Columns the user has used, columns the user collapsed themselves, and sessions with content are all left alone. The first call may precede the seat's mount (it throws, which is treated as "not yet"), so the implementation retries at [0, 120, 400, 900, 1800] ms and retires the session on success; both the subscription and the retries are bound to the plugin's own fiber, so a hot swap leaves no wild timers behind.

Cross-window bridge

  • The sidebar and the standalone page are two same-origin documents sharing localStorage, so they talk through the storage event (which only fires in the other documents, never in the writer — exactly right for sending a message). Key names and payload shapes are centralised in src/shared/bridge.js: the session (main → popout), settings and the divider position (shared both ways), and the @-reference request and its acknowledgement (popout → main → receipt).
  • The @-reference round trip: the standalone page writes a request carrying a nonce and a timestamp → the main window's storage handler writes it into the session composer through the same API the panel uses → it writes back an acknowledgement carrying the same nonce → the standalone page reports "inserted into the main window's composer", and only if no acknowledgement arrives (the main window is closed / storage is unavailable) does it fall back to the clipboard. A request is valid for 10s, so a stale request left in storage is not mistaken for a fresh instruction when a new window loads; and an acknowledgement's ok must be a strict boolean — anything malformed counts as "not inserted".
  • Settings and the divider are read and written by both sides through the shared module only, so a change on one side takes effect on the other immediately; scripts/check.js verifies these key names are not hard-coded anywhere else, ruling out "each side writes its own key and the feature silently dies".

Sidebar vs popout: known differences

Both sides share one set of data routes, one set of src/shared/ parsing and rendering, one set of cross-window keys (src/shared/bridge.js) and one settings shape (src/shared/settings.js), and the file tree works by the same semantics on both (expansion loops, per-directory refresh, persisted expansion state, search de-duplication and truncation notices, menu indexing, keyboard and aria, request races and retries are all aligned). The differences that remain on purpose:

Sidebar panel Standalone popout page
Layout On the native surface each view is a system tab (the switcher is the system's tab bar, so a native view draws no switcher strip of its own — only the ledger and Git each carry an information strip belonging to them), and a document tab in the ledger takes the full panel width; in the floating form it is a single column with tabs: artifact list / file tree / Git / one per open file, plus a "Jobs N" that only appears while background tasks really exist Two columns: preview on the left, list / file tree on the right, with a draggable divider
Theme Follows the shell theme (body[data-ds-dark-theme]) Fixed to light by default (more stable on a second monitor); ?scheme=dark / ?scheme=auto are available
Copy feedback An inline "copied" label (the panel is narrow and an overlay would cover the content) A toast at the top
Background jobs On the native surface a system "Jobs" tab, in the floating form a "Jobs N" on the strip (both read the session store's jobsBySession) Not applicable: the standalone page has no session store, and jobs belong to the main window
Change review Line-level diff + Undo (the same rendering and the same /revert route) Line-level diff + Undo (also calling /revert)
Editing CodeMirror 6 with Ctrl+S, the same shared mount; the dirty ● rides the file tab, and closing a dirty tab asks twice The same editor and the same save route, driven from plain DOM; the toolbar rides the preview's own bar, and unsaved drafts are covered by the browser's leave-page prompt (the page has no tab strip to mark)
Panel width Draggable left edge + "default panel width" (default 26%) / "minimum panel width" Not applicable (a full page)
Context menu Portalled into <body> (the panel is a container-type containing block) Attached to <body> directly
@-reference Writes straight into the session composer Writes back into the main window's composer across windows (copying when the main window is gone); the menu also offers "copy @-reference"

What is aligned: artifact polling (both 2s, both governed by the "auto refresh" switch, and with it off both still refresh once when you switch back to the tab), the divider position (the popout page's dragged value is written to shared storage and survives a reopen; "popout preview width" changes it from settings), the file tree's minimum width (280px plus the 38% yield, with all four values — on both sides, and in their constants and stylesheets — identical), file icons (both sides use the same document glyph, coloured by type), change review (one src/shared/linediff.js, one /revert route, the same CAS refusal semantics), and settings (the standalone page reads the same settings and follows them immediately).


Layout

.
├── README.md             # English edition (this file, the one the repo shows by default)
├── README.zh-CN.md       # Simplified Chinese edition
├── LICENSE
├── package.json          # static bundle metadata (main / exports ./client / dsh.bundle / dsh.client)
├── cordis.patch.yml      # the bundle mount patch (`dsh plugin add` picks it up automatically)
├── start-dsh.bat         # a small local script that starts `dsh web` (local convenience, not shipped in the npm package)
├── docs/                 # engineering notes and research archives (the directory is gitignored, but the notes below are tracked — see the index in docs/README.md)
│   └── logo/             # the brand assets, GENERATED by scripts/logo.js — mark / mono / lockups / rasters / the design sheet (see docs/logo/README.md)
├── scripts
│   ├── build.js          # the assembler: splices src/{shared,host,client} into the two single-file bundles below
│   ├── check.js          # the guards: embedding hazards + cross-window key drift + shared-module unit tests + panel width policy + layout constant agreement + bundle freshness + both bundles loadable + the reference bridge round trip + route/auth behaviour + native tab registration (four paths) + the lent renderers (Markdown / tables / Office / the PDF fallback) and the lend status + the type table / table parsing / byte ranges + the media Range contract + the new views' stylesheet inventory + settings matching the form actually in force + the Git read-only slice + file tree behaviour
│   ├── domstub.js        # a fake DOM: runs the standalone page's inline script in Node (so it really clicks buttons and right-clicks)
│   ├── minireact.js      # a mini hook runtime: mounts the sidebar's FileTree component in Node
│   ├── tree-tests.js     # file tree behaviour tests: expand/collapse all, context-menu position and portal, expansion memory (the same assertions for both sides)
│   ├── cdp.js            # a zero-dependency CDP driver: launches the machine's Chrome/Edge, dispatches real mouse/keyboard input, takes screenshots (Node 22 ships WebSocket)
│   ├── browser-tests.js  # real-browser tests: a stub host plus the assembled popout page, asserting "what the user sees" with real clicks / right-clicks / double-clicks / keys
│   ├── logo.js           # the mark's single source of truth AND its generator: the SVGs, the design sheet, the social card, the PNG rasters (and --ascii, to read the rendered mark as characters)
│   ├── png.js            # a zero-dependency PNG reader (Node's zlib), so the logo guard can assert what the committed rasters really look like
│   └── precommit.sh      # a pre-commit guard: verifies and blocks the commit when the bundles are stale
└── src
    ├── index.js          # the static host entry (ESM): evaluates the host.js body and exports it to the loader
    ├── host.js           # ⚙️ generated: the single-file host (produced by scripts/build.js, do not edit)
    ├── client.js         # ⚙️ generated: the single-file client bundle (produced by scripts/build.js, do not edit)
    ├── shared/           # reusable pure functions shared by both halves (portable JS, no template literals)
    │   ├── bridge.js     #   the cross-window protocol: localStorage key names + @-reference request/reply codec + freshness rules
    │   ├── settings.js   #   the settings shape: defaults / ranges / normalisation + the panel width policy (one copy for the sidebar and the popout page)
    │   ├── paths.js      #   workspace path comparison: separator / case / trailing-separator normalisation, "is this under that" (one copy for both file trees)
    │   ├── ext.js        #   extension → preview type and file-icon class (extType / fileExt / fileIconKind; five classes: table / audio / video / office / document)
    │   ├── table.js      #   delimited-text parsing: delimiter sniffing + RFC 4180 quoting + numeric sorting (one copy for the panel and the popout page)
    │   ├── range.js      #   HTTP Range parsing and Content-Range generation (what makes the audio/video scrubber draggable)
    │   ├── markdown.js   #   a minimal Markdown → HTML converter (code-block highlighting, math, mermaid / jsxgraph fences)
    │   ├── office.js     #   the Office mount layer: type table + expansion gate + lazy library loading + the docx/xlsx/pptx widgets (one copy for the panel and the popout page)
    │   ├── office.css    #   the styles for those three widgets (inlined into both the panel stylesheet and the popout page's <style>)
    │   ├── gitslice.js   #   the Git read-only slice: --porcelain=v1 -z parsing, grouping and counting, remote URL redaction (one copy for host and client)
    │   ├── linediff.js   #   line-level diff (LCS, degrading to "strip the common prefix/suffix" past a limit) (one copy for the panel and the popout page)
    │   ├── format.js     #   small formatters such as relative time (so the two sides never contradict each other)
    │   └── highlight.js  #   a zero-dependency syntax highlighter (tok-* tokens)
    ├── vendor/           #   every directory ships its own LICENSE and is served as a public static asset alongside the library
    │   ├── pdfjs/        #   bundled pdf.js (pdf.min.js + pdf.worker.min.js + LICENSE) for offline PDF preview
    │   ├── mathjax/      #   bundled MathJax (tex-svg.js) for offline math rendering in Markdown
    │   ├── mermaid/      #   bundled Mermaid (mermaid.min.js + LICENSE) for offline diagrams in Markdown
    │   ├── jsxgraph/     #   bundled JSXGraph (jsxgraphcore.js + jsxgraph.css + LICENSE) for offline interactive geometry in Markdown
    │   ├── jszip/        #   bundled JSZip (MIT build + LICENSE), docx-preview's ZIP layer
    │   ├── docx-preview/ #   bundled docx-preview (Apache-2.0 + LICENSE), Word rendering
    │   ├── xlsx/         #   bundled SheetJS (Apache-2.0 + LICENSE), Excel workbook parsing
    │   └── pptx/         #   bundled @aiden0z/pptx-renderer (Apache-2.0 + LICENSE, ESM), PowerPoint rendering
    ├── host/             # the host half's modules (the Node process)
    │   ├── body.js       #   the skeleton: inject / apply + placeholders
    │   ├── core.js       #   constants + artifact tracking + file operations + RPC
    │   ├── page.js       #   the standalone tab's HTML (an inline script pulling in shared)
    │   └── routes.js     #   the /dsh-sidebar-frog/* HTTP routes
    └── client/           # the client half's modules (the browser)
        ├── body.js       #   the skeleton: the __ModuleLoader__ factory + placeholders (react + optional primitives / react-dom)
        ├── core.js       #   store / settings / session helpers / the cross-window bridge and shared state / watchShellRight (measures the system right sidebar and publishes --dsh-sidebar-frog-right)
        ├── styles.js     #   the injected CSS (toolbar / tab strip drawn with Harness values)
        ├── icons.js      #   inline SVG icons (panel · collapse · popout · folder · file · toolbar)
        ├── preview.js    #   renderPreview / CodeView / TableView / MediaView / OfficeView / PdfView / diff / MathJax / Mermaid / JSXGraph
        ├── filetree.js   #   the IDE-style file tree (lazy loading, per-directory refresh, filter / search, portalled context menu, keyboard navigation, expansion memory)
        ├── components.js #   ArtifactsContent (shared by both faces) / ArtifactsPanel (the overlay) / CornerButton / the settings panel (including "lend status")
        ├── native.js     #   the native tabs registered into the system right sidebar (types + keyed bodies), including probing and rollback
        ├── docpreview.js #   the four renderers lent to the system document previews (Markdown / tables / Office / the PDF fallback) + lend status + file address parsing/synthesis + renderer hand-off
        ├── usage.js      #   usage/context: a system tab reading the useProjection its seat injects
        └── git.js        #   the Git read-only slice: a system tab (branch / ahead-behind / change list / diffs against HEAD)

After editing sources under src/shared/, src/host/ or src/client/, run npm run build (or node scripts/build.js) to regenerate src/host.js and src/client.js before committing — at runtime DSH only loads those two generated files.


Development

npm run build        # reassemble src/host.js and src/client.js
npm run check        # everything (the last step launches the machine's Chrome/Edge for the real-browser suite)
npm run check:fast   # skip the real-browser step
npm run test:browser # only the real-browser suite
npm run logo         # regenerate the brand assets (docs/logo/**); --ascii prints the rendered mark as text

npm run check watches for the classes of problem that look fine to the eye and only blow up in a browser:

  1. Embedding hazards — a module spliced into a String.raw / template literal containing a backtick, a ${, or a literal </script> (this round it caught even a backtick inside a comment).

  2. Cross-window key drift — the dsh-sidebar-frog:* keys may only be defined in src/shared/bridge.js; a hard-coded one anywhere else is an error, because each side writing its own key makes the feature die silently.

  3. Stale / unloadable bundles — sources edited without rebuilding; both bundles are parsed and smoke-executed. Adding --fix rebuilds them when they are stale.

  4. Behaviour regressions — shared-module unit tests: the @-reference request / reply round trip, malformed payloads, the 10s freshness rule, settings normalisation and clamping, and the line-level diff (context preservation, add/remove counts, new file = all additions, degrading instead of hanging on huge input); then the host is started against a stub context and all 20 routes are driven one by one (the list is checked for completeness, so a missing one is an error), measuring query parsing and authentication, plus the main-window half of the reference bridge (really writing @path into the composer and acknowledging, not writing a stale request, answering ok:false when there is no composer).

  5. Layout rule agreement — the file tree's minimum width (list 280px / 38%) is spread across four places (the panel stylesheet, the panel width policy, the popout page's stylesheet, the popout page's drag clamps), and any drift ellipsises long file names on one of the sides, so all four must agree exactly; the panel width policy (default 26%, drag wins, the floor applies) and the popout preview default of 80% (three values: the default, the popout page's fallback, the popout page's stylesheet) each have their own tests.

  6. File tab wiring — clicking a file must become a tab in the panel (openFileTab / closeFileTab / renderPreview / artifacts-tab-file in the tab strip) and must not open a browser window through window.open again; the preview must be full panel width (not squeezed back down by the popout page's percentage width).

  7. Switching tabs must not rebuild the file tree — the panel's tabs merely hide the inactive pane (visibility: hidden + absolute positioning) and must not be written as "render only the active tab": that would unmount the tree every time you leave it, losing expanded directories and scroll position together, which the user experiences as "this tab refreshes from scratch every time I come back". The check watches both the implementation (no activeTab === 'tree' ? conditional rendering in components.js) and the styles (a hidden pane must still be in the layout).

  8. The top-right toggle must not fight the system's — the system puts its own sidebar toggle at the top-right of the session title bar too, and our panel happens to narrow that very column, so a fixed offset necessarily overlaps. The permanent toggle's position is therefore measured from the system's button ([data-sidebar-right-expand]) and written into --frog-corner-right, and the check watches all three parts: that it really measures, that the stylesheet really uses that variable (with a fixed fallback), and that the override is really cleared when the button disappears.

  9. Responsive guards — no responsive rule may apply display: none / visibility: hidden to a control (hiding a tab is fine; hiding a button deletes a feature). The file tree's toolbar was once hidden as a whole by @container (max-width: 400px), and clicking did nothing.

  10. Change review / undo — drives the tools/execute wrapper (the complete before/after text only exists in a live execution result) and asserts: an overwrite must be tagged edit (an early version tagged every write as create, which once undo existed would delete the user's original file), snapshots are undoable and the diff carries the real before/after text, undo uses replaceIfVersion + the written version as CAS and restores the right content, a file modified again after the change is refused without touching the disk, a new file explicitly reports "not undoable", the parameters-only fallback path must not tag a known path as new, and GET /revert → 405 with bad JSON → 400.

  11. Reusing system capabilities — ① the right sidebar's width must measure the system panel element ([data-sidebar-right-panel]) and publish --dsh-sidebar-frog-right, and falling back to that unwritten --dsh-sidebar-width is forbidden (the yield would silently stop working), with a ResizeObserver following it and cleanup on close; ② background jobs must read the system's jobsBySession mirror and must not build their own runner or start / stop tasks.

  12. Native tab registration (all four paths driven) — starting both client halves against a stub context, it asserts one system tab per view: the registered types are exactly files / frog-artifacts / frog-jobs / frog-usage / frog-git (compared id by id rather than counted, because adding a view should mean adding its own entry); each is an extension-band page type (no patterns), has a usable title, and carries exactly one guide-page entry (with title, description and order, all five orders distinct); and the file tree must occupy the system's own files kind (occupying a kind of its own would leave the system's plain tree — the one with no @-references and no context menu — in the sidebar). Every type's body must register into sidebar.right.pane.tab with a key equal to its own id (the seat looks the body up by entryKey = id) and must go through slots.inject (that seat is a child of rightbar.session and does not exist before the session view mounts), and it must not register the overlay's entry point / panel — that is exactly the duplicate control being deleted. Three further paths: with no registry it falls back to the overlay and registers no native tab; when the registry rejects it, it rolls back cleanly (all five types together, leaving no shell with a type but no body); and with the setting off it registers none of these kinds or their bodies, and the overlay must come back (otherwise switching the setting off leaves no panel at all).

  13. The duplicate "collapse sidebar" button is gone — while the panel is open the top-right entry point must not render (if (open) return null), the collapse button moves into the panel's own toolbar (and the stylesheet really has a button box for it); the entry point may no longer claim to control the open state either. On the native surface, no panel toggle is drawn at all. This item is not just a source read: scripts/check.js mounts the component that was really registered with scripts/minireact.js and renders it — open, the top-right entry point must render null; closed, it must render "open sidebar" and not "collapse sidebar".

  14. The native surface no longer brings its own window frame or yields by itself — the floating panel's "reserve a right margin on <html> #root" must run only in the floating form (on the native surface the system has already laid out the column, and pushing again would squeeze the conversation), and the panel geometry (minimum width / width) must not be computed for the native form either; the stylesheet must contain .artifacts-panel.artifacts-panel-native, dropping fixed positioning, shadow and seams together. There are render-level assertions as well: with the registered tab body really mounted, the native form's panel must not have a drag handle, must not have an inline width, must not write --dsh-sidebar-frog-width and must not draw a collapse button, while the floating form must have all four (a collapse button has to live somewhere, and it moved into the panel's own toolbar).

  15. The Markdown renderer lent to the system — against a stub registry it asserts: exactly one definition is registered, the band must be extension (otherwise the built-in renderer keeps winning and the formulas and diagrams were all for nothing), the claimed extensions must be exactly the two the official Markdown renderer claims, and the delivery mode must be text-pages; the body must register into sidebar.right.tab.document with a key equal to the definition id and must go through slots.inject (that seat is a child seat declared by the official document previews). Then it really holds a contest: with the official builtin Markdown definition present in the stub too, the registry must rank our implementation ahead of it. Switching the setting off asserts the registration is withdrawn and .md falls back to the official renderer; then, through the cross-window settings channel (a real storage event), that the withdrawal and the restore each happen exactly once (registering twice throws because the id is taken, so "exactly once" is a hard constraint); and with no registry present, that nothing is lent and the rest of the plugin is unaffected.

  16. Renderer hand-off (consuming the registry) — driving the panel with a real mount and a usable host stub: opening an .odt this panel can only show as text, while another renderer in the registry claims that extension, must produce a notice at the top of the preview that names that renderer and offers "open in the system sidebar"; the same .docx must be given to this plugin's own OfficeView instead (not the card — we can already draw it); opening .md must not produce that notice — and that has to hold in both states (lend on: we win; lend off: the official renderer wins but the notice still must not appear), because the second state is the real evidence that this notice is about what this panel cannot draw, not about who wins in the system.

  17. Usage is a system tab, and the occupancy number must match the system's — it asserts that frog-usage is a page type registered into ctx.sidebarRightTabs (extension band, no patterns), that its body goes into the keyed seat with a key equal to the type id and must go through slots.inject; that it must carry its own guide-page entry (title / description / order, order 40) and that no "usage" button may appear on any of our strips (a second entry point for the same thing); then it really mounts the registered tab body, feeds it a projection and asserts that the occupancy percentage equals the product's own rule (projectedTokens before pressureTokens: 120000/200000 = 60%, clamped to 100% past capacity), that the breakdown bar's three segments are proportional (33.3% / 16.7% / 50%), that the cumulative bucket uses the compact form (1.2M), and that the wording is the product's own (system prompt / tool definitions / conversation messages); with an empty projection it must explain each block rather than drawing a number nobody reported.

  18. Tabs in old sessions must not become orphans after a kind change or a retired view — the right sidebar's layout is persistent, so any kind/id rename or retired view leaves existing sessions with tabs whose body cannot be found by definition?.id ?? tab.kind, and the system renders "there is no available way to view this kind of content." in that position. The check watches three things at once: ① no tab type may be registered for a retired kind (a retired name gets a body only, never a type); ② all five current ids and every retired kind (frog, frog-browser) must have a body (FROG_LEGACY_TAB_KEYS), with no duplicate keys; ③ really mounting the retired kind's body must draw the panel showing the view it used to be (the artifact ledger's strip and list), not the new tab's default file tree.

  19. Do not draw what the system already drew — on the native surface there must be no panel toolbar of our own and no self-drawn view-switching chip (the system's tab bar is the switcher), and the ledger's single strip may only hold the open document tabs, "Clear" (artifacts-tab-action, sticky so any number of file tabs cannot scroll it away) and one "N artifacts" label (which is what makes that strip read as a header rather than a nearly empty bar); the floating form is the mirror image: it must have its own toolbar, its must have the artifacts / file tree switching chips and must have a drag handle, while "Clear" may exist exactly once (a second one means the native set was copied over). Transient feedback always renders into the system's own framework-level overlay shell.overlay: the check asserts it registers into that seat, renders null while idle (it should not add a permanent cost to the overlay) and drives one real piece of feedback end to end (the popout page requests an @-reference over the cross-window bridge → this window writes @已插入… into the pill).

  20. Where a click on a file goes — the native surface's file tree has no file tabs of its own, so a click must hand the file to the system: it asserts that sidebarRight.openResource receives exactly dsh-resource://file/session/s1/D:/ws/notes.md (both the session id and the path encoded correctly), letting the system open a document tab in the same column; the floating panel, on the other hand, must still open its own file tab even when the system service is available (its file tabs are a feature of the panel, not a substitute for the system's strip), so that click must not call openResource. Both are driven by the tree component's own onOpen — the very function a row click takes.

  21. Type table / table parsing / byte ranges (pure functions, 32 + 20 + 15 assertions) — extType() is asserted extension by extension across 32 extensions, three of them deliberately: SVG stays image (<img> does not execute scripts, which is the security reason it is not inlined), docx/xlsx/pptx are office and not text (calling them text would send 200 kB of ZIP bytes out as UTF-8), and .doc / .xls / .ppt / .odt / .ods / .rtf / .epub stay document (only a container with no reader deserves that card — claiming an extension means really being able to draw it). The table parser is fed a "real export": a BOM plus CRLF, delimiters inside quotes, "" escapes, ragged rows with missing values, plus a trailing newline that must not invent an extra row and a lone \r that is also a newline; delimiter sniffing must recognise tabs / semicolons / pipes, and a single-column file must not be split apart; sorting must be numeric (2 before 10, where lexicographic order would put 10 first), empty values last in both directions, ties keeping file order; and number detection must be strict — 1,234, 12% and 2026-01-02 are all text. The byte-range parser asserts: closed ranges, open ranges (to end of file), suffix ranges (bytes=-50, a player's opening probe of the tail), out-of-range endpoints clamped, an out-of-range start giving 416, inverted ranges invalid, multiple ranges degrading to the whole file, and no satisfiable range for an empty file.

  22. Render wiring for the new types (render level) — getting extType right while the preview still draws code is the same old bug. So the registered panel is really mounted, three kinds of artifact are opened, and it asserts that the preview body is handed to TableView / MediaView(audio) / MediaView(video), and that images / audio / video do not go through the text read (not one artifacts-code may appear). The table's own behaviour is driven through the same component (mount the hook-free seat body first, then mount the table as the root so its state is observable): the header text, the status line (3 行 × 2 列 · 分隔符:逗号), the initial order, one click on a column header sorting numerically ascending, aria-sort following, and a third click restoring the file's original order.

  23. The second lent renderer — csv / tsv must be registered at the extension band (otherwise the built-in plain-text fallback keeps winning and the table was for nothing), the claimed extensions must be exactly those two, title must be a callable function, the body must register into sidebar.right.tab.document with a key equal to the renderer id, and it must reuse the panel's TableView (by asserting that the component the body hands to is that one). The easiest thing to get wrong is two switches sharing one registry: hence the dedicated assertions that switching the Markdown lend off does not withdraw the table one along with it, and that with both off the table registration really is gone and candidates('.csv') no longer returns us.

  24. The media route's Range contract + binaries are never sent as text — four requests against the real route with every header asserted: no Range → 200 + Accept-Ranges: bytes; bytes=0-1 → 206 + Content-Range: bytes 0-1/4 + Content-Length: 2; bytes=-2 → bytes 2-3/4; a start past the end → 416 + bytes */4; multiple ranges → back to the whole file with 200; HEAD → headers with no body; and .csv's Content-Type is text/csv. The same block asserts that the host never sends binaries as text: .docx comes back with type office and .odt with document, and both have an empty content; bytes only arrive with text=1, while .csv is honestly tagged table.

  25. The new views' stylesheet inventory — the views are class-driven, and a class with no rule renders unstyled, which no other check can see (the table still draws, just wrongly) and neither can a diff read (the markup looks complete). The panel's styles.insert() and the popout page's <style> each carry an inventory requiring a rule beginning with that class — beginning with, note: .artifacts-table-td.is-number and .artifacts-table-tr:hover .artifacts-table-td both contain that name, so a substring match keeps passing after the base rule is deleted (this one was found by mutation testing, not by reading the code).

  26. Every view on the native surface must draw something itself, and a setting must not be a fake switch — this one looks at the gap between the two halves: the documentation says "the system's Files tab is this plugin's file tree", while in the code that view was still governed by the floating panel's "file tree" switch. With the switch off, that system tab stayed and went blank (no tree, no list, no error) and nothing in any log said a word. So the assertion runs against the body that was really registered: switch the item off, mount the native Files tab, and there must be exactly one visible pane, whose component must be the file tree — recognised by props only the file tree receives (onOpen plus selection / pin / ledger), not by function identity (every boot produces a new function object). The same item watches the settings page itself: on the native surface "file tree" must not appear (each view already owns a system tab there, and this item could only blank one), while the floating form must have it; and it reconciles the "default panel width" description with DEFAULT_SETTINGS.defaultPanelWidth — long after the default fell from 65% to 26% the text still said 65%, and that kind of drift has no second check that can see it (the wording is a plain string; neither the host nor the browser errors over it).

  27. Git is genuinely read-only (this reads the built bundle, not the source files) — every gitRun(cwd, [...]) verb in the bundle is extracted and asserted to be inside the read-only allow-list (rev-parse / status / rev-list / config / show); then that the number of call sites equals the number of literal vectors (a gitRun(cwd, args) that computes argv would expose itself immediately, because the allow-list only sees literals); then that the whole host bundle has exactly one sub\.spawn( call site and no node:child_process / execFile / spawnSync (git must go through the system's ctx.subprocess, or it would bypass the deadline, the sanitised environment and the allow-list at the same time); "no shell strings" is asserted inside this function's body only — the bundle also contains the enormous strings of the bundled renderers, and an exec( somewhere in a library has nothing to do with this code (the first version reported exactly that false positive). It also asserts that the 6-second AbortController deadline and resolveExecutable('git') are present.

  28. Git parsing and presentation — the parser is driven with real --porcelain=v1 -z output shapes: paths containing spaces, non-ASCII paths, MM (present in both the staged and unstaged groups), UU (conflicted) and a rename (whose original path is the next NUL record and must neither become a ghost entry nor eat the next record). It then asserts the group order, the counts (files in two groups count once in total), the ↑2 ↓1 phrasing, that ↑0 ↓0 is not printed without an upstream, and the redaction of remote URL credentials (userinfo, the scp form's user, and a token in the query string — three shapes). At render level it really mounts the Git tab: the branch, the title "与 HEAD 的差异" (calling it "edit diff" would claim we changed it), the four group headings, the line counts, and clicking a row lazily loading that file's diff and drawing it with the panel's own diff renderer. Each of the four not-a-repository reasons is asserted once, and none of them may draw a list.

  29. The floating strip only announces views that exist — the floating panel is the fallback form, so it must offer the same views as the native column and must not keep advertising something that was deleted: it asserts that the strip has Git and not "browser" (the view went away together with its /site route, see §9).

  30. The removal of the built-in browser is pinned down — browser view removed reads the build output directly: the host bundle must no longer contain '/dsh-sidebar-frog/site (the route and its token route), siteToken / randomSiteToken / splitSiteRequest / readSiteFile / peerIsLoopback / SITE_MIME (the token and file-fetching machinery). The reason is not fastidiousness: that route was the only data entry point in the plugin that did not check the browser cookie, so bringing it back must be a deliberate decision carrying the original security guards — not something a merge quietly reinstates. Two other guards from the same round: the new session's default page and permanent entry point (a blank session's "Files" page is opened automatically; with "expand on load" off, a non-blank session, a column that already has content, or the takeover switch off, nothing may be touched; and the session subscription must be released with the fiber) and the "File tree" button in the left column (registered only on the native surface, calling openTab('files') when clicked, labelled when expanded and not when narrow).

  31. Hot swap (really driven once) — this is the guard for the feedback that the panel "always fell back to the original interface and needed a restart every time". The system's hot swap is "let the old fiber's disposers finish → remount the new bundle", so the check splits it into two real steps: after dispose() no registration may remain (any that does outlived its own module), and a second apply() must still produce the same five tabs, without falling back to the floating panel (not one dsh-sidebar-frog-panel / -trigger may appear). The stub registry also throws on a duplicate id, exactly like the real contract, so the chain "leak → duplicate rejected → rollback → floating panel" is really walked. The same guard also checks that every registration happens inside ctx.effect (it records any registration that cannot be bound to the fiber) and covers two quieter consequences of the same kind: the lent renderers are still registered after a hot swap (four of them now: Markdown / tables / Office, plus the PDF one this engine's capability takes over) and the stylesheet carries data-plugin and is replaceable in place. Mutation testing confirms it: removing bindLifecycle(...) reports exactly the sentence the user saw.

  32. Office documents (read offline) — this group watches the junction of "three containers × four libraries × three rendering faces", and every item corresponds to a failure that only shows up in a browser:

    • office kinds + expansion guard (pure functions): the extension table (docx/xlsx/pptx are ours, .doc/.odt/… are not); officeZipTotals() reads the central directory of a really constructed ZIP (entry count, compressed and uncompressed totals), and garbage bytes or an empty buffer must return null (no false positives); officeBytesVerdict() lets a normal package through and rejects an empty file (empty), one over 24 MB (too-big), and a package declaring a 3 GB expansion (too-expanded), and the refusal wording must state the actual size (3 GB). The zip bomb is the only security claim in this area, so it is driven by real data.
    • lent office renderer: exactly one registration, the extension band, exactly the three extensions, and loading must be bytes-complete (writing text-pages means handing page after page of text to a ZIP parser), wrap:false, the body registering into sidebar.right.tab.document with a key equal to the renderer id, through slots.inject; candidates() must return us for all three extensions and must not return us for .doc (claiming an extension we cannot draw).
    • office lend (live): three switches share one registry, and the easiest thing to break is "switching one off withdraws another" — with the Office lend off, .docx no longer matches us while Markdown and tables must still be there; switching it back on restores all three, and the withdrawal happens exactly once.
    • office document body (rendered): really mounting the registered body, feeding {kind:'bytes'} must delegate to OfficeView (with the kind and the bytes), and feeding {kind:'text'} must degrade to a notice.
    • office view (panel): the panel only receives a path, so it asserts that it really fetches bytes from this plugin's /media (both the URL and the path must be right) and that when that read fails (HTTP 500) what is drawn is an error line with the reason, not a blank.
    • office assets: each of the four URLs in src/shared/office.js must have a route, the content type must be JavaScript, the file really returned (fingerprinted by library name), and a LICENSE must accompany it (docx-preview is Apache-2.0, JSZip offers an MIT build). URL-and-route drift is a 404 that only shows up in a browser as "loading forever".
    • office in both faces: the shared module and the shared CSS must appear both in the client bundle and in the popout page's <style> — missing one side means "Office files do not work in that window".
    • Finally the real browser: scripts/browser-tests.js renders all three formats with real libraries and fixtures built on the spot (see below).
  33. The PDF capability fallback, and "did the lend actually take?" — the product's PDF renderer (pdf.js 6) calls Map.prototype.getOrInsertComputed / getOrInsert, and without them the system tab can only show one line of error, with no way to patch it (it builds pdf.js's Worker inside its own Blob, so the throw happens in a realm this plugin cannot reach). So this group drives both directions for real: first those two methods are removed from Map.prototype and it asserts that .pdf must be taken over by dsh-sidebar-frog/pdf — extension band, accepting only pdf (claiming other extensions would mean claiming what we cannot draw), bytes-complete, wrap:false, the body registering into sidebar.right.tab.document with a key equal to the renderer id, candidates('report.pdf') returning us first, and feeding bytes to the body delegating to the panel's own PdfView; then the methods are put back (restored exactly as they were in finally, because every later check reads the same Map) and it asserts that not one registration is made and .pdf is left to the system. There are five reasons a lent renderer can fail to take effect, and inside the panel they all look identical, so the settings page's lend-status block is itself guarded: all four extension groups must be present (md / markdown, csv / tsv, docx / xlsx / pptx, pdf), every row must carry a state, and the browser's PDF case must be named; the same group also pins down that "every boolean setting must have a clickable switch" (a new setting is the easiest thing to ship the feature for while forgetting the switch).

  34. The popout entry point must exist in both forms, and the two buttons in the left column must be stacked — the floating panel's own two links to the popout page (the panel toolbar and the top-right entry point) are never registered on the native surface (src/client/body.js registers them only when !frogNativeSurface), so the native form once had no entry point at all, while the README kept promising "one-click popout"; this item is that regression's guard. The assertions come in two halves:

    • Native form: sidebar.footer.action must hold exactly one registration (id fixed as dsh-sidebar-frog-foot), and the single occupant it renders stacks two buttons in order — 文件树 (data-frog-footer="files", a <button>, calling openTab('files') when clicked) on top and 弹出页 (="popout", which must be an <a> rather than a button calling window.open — a blocked popup returns null and the click looks dead) below; that link's href is the popout address carrying a sessionId (without it the file tree roots itself in the wrong workspace), its target must equal the one browsing context all four links share, and it is labelled "弹出页" when expanded but not when the column is narrow. "Why exactly one" is itself asserted: registering two is an error (the system lays that seat out as a row in both collapsed states, and a 56px column minus 10px of padding on each side is a 36px content box — exactly one 36px round button, with the second pushed outside the column, which is precisely the symptom the user reported), and the direction is read from the stylesheet (the .artifacts-foot-stack rule must declare flex-direction: column, the expanded state must use align-items: stretch with a higher-priority rule overriding the old calc(100% + 4px) inline width, and the narrow state must not carry is-wide). In addition sidebar.right.tab.menu.item must be registered through slots.inject (that seat hangs under the session view and only exists after it mounts, and its id is pinned too), and it must draw a role="menuitem" link for every kind this plugin owns (five current plus two retired names) while drawing nothing at all for a foreign kind (the guide page, another plugin, an unknown one) — otherwise someone else's panel grows a button that opens this plugin's page; clicking must also close the menu (the menu is the system's and only closes on its own actions).
    • Floating form: neither native entry point may be registered (that column is not ours, and the panel's own two links are already there). Mutation testing verified every item (all 8 mutations this round were caught): registering two again → the footer seat holds 2 registration(s) — the shell lays that seat out as a ROW…; changing the stack's flex-direction to row → no .artifacts-foot-stack rule declares flex-direction: column…; swapping the two buttons → the footer stack holds ["popout","files"], want 文件树 above 弹出页; giving the narrow state is-wide too → the collapsed footer stack claims is-wide…; dropping is-wide from the expanded state → the expanded footer occupant does not carry is-wide…; deleting the rule that overrides the old row width → the wide rows are still sized by the old flex-ROW rule…; deleting the footer button → the native surface offers no 弹出 entry in the shell's footer (…) — the panel's own two links are not registered on this surface, so the popout page has no way in; removing "only answers for our own kinds" → the three foreign-tab assertions fire at once; dropping sessionId from the href, or changing target → each is caught; registering the native pair in the floating form too → popout entry (floating surface) errors; deleting the menu row's base style rule → view styles (panel) errors (it catches it because the style inventory asks for "a rule beginning with that class", not "a mention of that name" — the same lesson as item 25).
  35. No backticks or ${ in the panel stylesheet — scripts/build.js splices src/client/styles.js whole into a template literal (the client's styles.insert(...)), so a single backtick or ${ anywhere in that file (including inside a CSS comment) ends the string early and the whole client bundle stops parsing. The failure looks wildly misleading: every guard that needs to boot the client reports missing ) after argument list at the head of a 2MB file instead of the offending comment line (this is exactly what happened this round). So it is now checked on its own, and it reports the line number directly.

  36. The two language editions must not drift — README.md (English) and README.zh-CN.md (Simplified Chinese) are two copies of one document, and the easiest mistake is editing only one of them: structural drift (a missing section, table or code block) is invisible in a diff, while readers install from whichever copy is out of date. So this item asserts against both files at once: each opens with a language switcher pointing at the other; the two have exactly the same number of ## headings, table rows and code fences; both must carry the install command on a line of its own dsh plugin --profile web add github:zengqingsong/dsh-sidebar-frog (a line a reader can copy — a mention in prose does not count; a copyable install command in the README is one of the plugin marketplace's hard listing requirements, so lose it and both the listing page and one-click install stop working); every in-document anchor in either file must resolve to a heading that really exists in that same file (this is what caught the "Sidebar vs popout" anchor that had been dead ever since it was written — the Chinese heading says more than the link does); the English edition's headings must contain no CJK characters (the structure has to be English, or an English reader gets half a translation); and both files must state the fork's origin and the upstream copyright in their opening metadata block (an upstream credit at the bottom of a file is no credit at all). The English edition may carry CJK only inside a verbatim quote (backticks, double quotes or 「」), in the language switcher, or as the author's own name — a Chinese explanation added to it later fails this guard.

  37. Editing and saving (P1-10) — the only operation in this plugin a browser can trigger that changes the workspace, so it is driven end to end rather than read off the source. /dsh-sidebar-frog/save is on the DATA_ROUTES list (the unauthenticated sweep therefore calls it and expects 401), and a group of its own drives the real route against a stub filesystem: a clean save is written under replaceIfVersion with the revision the editor was opened on and answers the revision it produced; the same save is then found in the ledger (by: 'user', a real before/after diff, an undo the very next /revert performs — so Undo really does put a person's own save back); a save over a newer revision is refused with stale: true and a reason and writes nothing; force (the pane's 「仍然保存」) goes through but is still guarded on the freshly read revision; a path outside the session's workspace is refused with a reason; a CRLF file with a UTF-8 BOM is written back with both restored ('\uFEFFa\r\nB\r\n') and the answer states which; GET → 405, malformed JSON → 400, and text over the 4 MB ceiling is refused before the file is touched; and a path that is not an existing regular file is refused, because this pane never creates files. The editor's own asset is checked the way the Office readers are: the route answers JavaScript, the body is the real bundle (fingerprinted by DshFrogCM / markdownKeymap / insertNewlineContinueMarkup / openSearchPanel), an MIT licence sits beside it, the vendored README records the build command and the package versions a rebuild would need, both faces carry the mount and the save call, and both gate 编辑 on truncated — the one case where saving would shorten the file.

  38. 编辑 is offered where it is safe, and only there (rendered) — the panel decides that in one place (isEditablePreview) and hands the answer to the editor pane, so this mounts the panel for real, opens a file and reads the prop the pane actually received: a plain Markdown read is editable with the revision and size it read and with the read-only preview as its fallback body; a truncated read is not (that content is a prefix); an image is not. It also pins the Ctrl+S binding into the shared mount both faces call, because a save that only works by clicking the button is not a supported state. What this group cannot see is the editor itself — the mini hook runtime does not expand child components — which is why the real-engine suite exists: it clicks 编辑, waits for .cm-editor to mount from the vendored bundle, inserts text through the input pipeline, clicks 保存 and asserts the POST carried what was typed and the revision. That test caught a bug no source guard could: the popout page was mounting CodeMirror into the outer box instead of the child that carries the height, so the editor had a zero-height box and .editcm existed only in the stylesheet.

  39. The package manifest is a release surface — the listing page gives people exactly one command, dsh plugin --profile web add github:zengqingsong/dsh-sidebar-frog, and from there the CLI mounts this package by its manifest alone: dsh.bundle.patch becomes the profile layer, exports["./client"] becomes the browser bundle, dsh.client.inject is carried into the client wire table, and on a git install the artifact is the pack pnpm builds from that checkout: pnpm packs by files[], so scripts/ and docs/ are absent from an installed copy while everything under src is present (measured on an isolated profile — top level: src, cordis.patch.yml, LICENSE, package.json, README.md, README.zh-CN.md). There is no build step, because both halves ship committed. So this item reads package.json and cordis.patch.yml and asserts: the name is the one the install command uses; main, exports["."] and exports["./client"] all exist in the tree; every files[] entry exists and covers what the runtime reads (src/host.js, src/client.js, src/index.js, cordis.patch.yml, both READMEs — existence alone is not coverage, so dropping src from the list keeps every remaining entry valid and still ships a package that cannot boot); the patch's name: is this package (a row naming anything else mounts a name that does not resolve); dsh.client.platform is web; and every dsh.client.inject edge resolves in the DSH actually installed here, searched in each profile's node_modules, its .dsh-module-fallback and the global CLI install, skipped rather than failed when no DSH install can be found. That last assertion is what retires the stale @deepseek-ai/dsh-client-runtime: those edges are informational (the product's own workspace client says so), so a name whose package disappeared from DSH never fails at runtime — it just keeps claiming a dependency that is not there. Seven mutations were tried on this group and all seven were caught, one of them only after it had survived: deleting README.zh-CN.md from files[] passed until the coverage list learned about it — a hole found by mutation testing, not by reading. What made this item necessary was an install from a bare clone of this repository into an isolated profile: with src/vendor/ untracked, that install booted and carried 4 of the 9 vendored libraries, so the first Office preview and the first click on 编辑 would have 404'd on a package that looked healthy from the outside.

  40. The brand assets are generated, and must be the mark they claim to be — the logo is the one artifact here that no test can "run" and the one a reviewer is least likely to re-measure by hand, so three things are asserted instead of trusted. (a) The design rules hold in the source: the head is centred in its box, the eyes are mirror-symmetric about it and cannot touch (a gap under 8 units welds shut at 16px), the eye domes clear the head's edge, the pupils fit inside their eyes with room to spare, the mouth is centred and clears the chin, nothing leaves the 64-box, and the palette keeps its contrast (ink on jade ≥ 4.5:1 for detail, jade ≥ 3:1 on both white and the dark background for the silhouette). (b) Generated equals committed: the four SVGs, the design sheet and the social card on disk must equal what scripts/logo.js renders — a hand-edited SVG cannot quietly become the brand. (c) The committed rasters really look like that, decoded by a zero-dependency PNG reader (scripts/png.js) rather than trusted: transparent corners, a solid chin, coverage between 25% and 60%, mirror Δ under 6, two separate runs of opaque pixels at the eye row and one at the head row of the 16px raster (the favicon size — this is the assertion that would have caught the first cut of the mark, where the head's own edge bridged the gap between the eyes on all but one row), ink pupils on jade at 512px, and real knockout holes (alpha 0) in the mono variant. The popout page's favicon is then compared against the generator, because that page is the one surface that lives in a browser tab strip on a second monitor among a dozen unrelated tabs — its icon is how the user finds it again, and it is inlined as a data URI so it needs no route, no request and no auth. Finally both READMEs must reference a logo file that exists, since a broken image on the listing page is the first thing a prospective installer sees. The design rationale, the file inventory and the usage rules live in docs/logo/README.md; the sheet to look at is docs/logo/preview.html.

On the feedback that "the file tree seems to have lost its @-references and its context menu": that was not this plugin's tree breaking, but the system's own tree never having had either (in its bundle, contextmenu and reference actions appear zero times). Items 12 and 18 together are what keep that from happening again — once the kind becomes one of our own, the plain tree reappears in the sidebar and those two capabilities are exactly what the user loses.

File tree behaviour is the one part that can really run in a stub, so each side has its own driver: the standalone page's inline script runs against the fake DOM in scripts/domstub.js, and the sidebar's FileTree component is mounted with scripts/minireact.js (inlining src/shared/ alongside the component exactly as build.js does — otherwise the shared functions are undefined and an exception thrown inside an effect is swallowed by the hook runtime, so the test "silently verifies nothing"). The panel itself (items 12–14, 17–20, 26, 28) takes the same road: scripts/check.js uses that same mini hook runtime to mount and render the very components the plugin registers with the system (the five native view bodies, the floating panel's content, the left column's "File tree" entry point, the usage body) one by one, because "registered correctly" and "draws correctly" are two different things — an empty-shell tab, a duplicate collapse button, a stray strip in a native view and an entry point that does nothing when clicked all belong to the second. The tests (scripts/tree-tests.js) behave the way a user does — clicking toolbar buttons, single- and double-clicking rows (with detail counts), right-clicking rows, clicking menu items — and then assert the visible rows, the expanded set, React keys, the menu's position and portal target, and "does a failed directory recover when retried".

These cases lock down failures that look fine to the eye and only show up when something really runs, and each one was verified by reverting the fix to the old code and confirming the case really fails (all 8 mutations were caught, and what they print is the symptom the user saw):

  • the folder closed itself: open = [] — a double-clicked directory undoing itself because two clicks each toggled once;
  • the level was never requested again — a failed directory cached as "loaded", so collapsing and expanding it never asks again;
  • duplicate keys: ["load:1"] — several "loading" placeholder rows at the same depth sharing one React key;
  • stored "[]" — /listdir echoing the caller's path back verbatim, disagreeing with the realpath spelling of its entries, so expansion memory persisted as empty and everything collapsed on reopen;
  • path comparison not normalising separators, so two spellings of the same directory did not match each other;
  • the tree component throwing inside an effect (swallowed by the hook runtime, presenting as "the click does nothing", which is why the suite finally asserts "not one exception" across the board);
  • the "collapse all" button hidden by @container (max-width: 400px);
  • the context menu landing in the wrong place — a menu left inside the panel has container-type: inline-size making the panel the containing block for position: fixed, so it was drawn outside the panel (on a 1920px window it could miss by nearly a viewport width). It is portalled to <body> now, and the suite asserts both "it really went through a portal" and "the coordinates are the pointer's coordinates";
  • the file tree squeezed into a sliver when the divider is dragged all the way — the two-column minimums (list 280px / preview 300px) now take the same four values in four places, so 280px holds even at the limit and long file names are never ellipsised.

The P1-3 batch (tables / audio-video / binary documents) then had its own run of 13 mutations; 12 were caught on the spot, and what they printed was the symptom the user would see — changing cell sorting to lexicographic reported ascending gave b,a,c (want a,b,c); deleting the Range clamp reported bytes=990-5000 @1000 => end 5000 (want 999); short-circuiting the media route's Range branch reported a range read answered 200. The 13th (deleting the table cell's base style rule) survived, because that guard asked whether the class name appeared in the stylesheet, and a compound selector such as .artifacts-table-td.is-number still carries the name — in other words, it was testing a mention, not a rule. Changed to "a rule beginning with that class", it was caught on the rerun; and the new regex's omission of the popout stylesheet's indentation turned up along the way and was fixed too (that step was likewise reported by the guard itself).

Another lesson is that the test scaffolding itself lies: the first run of 13 mutations all reported SURVIVED, which looked like the guards were useless, when in fact check.js prints failures with console.error (stderr) while the mutation script only read stdout. Fix the scaffolding before drawing a conclusion — otherwise you go and "fix" something that was never broken.

In item 26's mutation testing, the same trap came back wearing a different face, and it deserves its own note: ① the first version of the guard read the rendered text, but SettingsToggle is a child component and the mini hook runtime does not render child components — so the native half "did not see the file tree" and passed vacuously (rendering nothing trivially satisfies the assertion), while the half that really failed was the floating one. Reading it off the element tree instead (function nodes carrying a label prop) made both halves really verify something. ② Another guard started out looking for artifacts-tree, the FileTree's own root node, which is likewise a child component and can never appear in the render result — after switching to "one visible pane, whose component is the file tree", the red and green states finally lined up. The lesson is the same one: an assertion must land on something the stub can really observe, or "passing" is just a quiet coincidence. All three mutations were then caught, reporting exactly the symptom the user would see (shows 0 panes, still offer 「文件树」 on the native surface, does not state the real default (默认 26%)).

The Office group (item 32) ran 8 mutations and all 8 were caught, each reporting a symptom the user would see: changing the registration to loading:'text-pages' → an Office file is a ZIP container, so it needs the complete bytes, not pages of text; deleting the expansion ceiling → a 3 GB expansion was accepted: {"ok":true,...}; hanging the Office lend off the Markdown switch → switching the office lend off left it registered; changing the panel's byte source from /media to /content → the office view asked for bytes 0 time(s); misspelling one asset URL → docx → /dsh-sidebar-frog/office/docx-preview.js; pushing docx back to document → a.docx => document (want office); dropping the shared CSS from the popout page → the popout <style> is missing the shared office rules; removing office from BINARY_TYPES → a binary document was decoded as text anyway (4 chars).

That last one first came out as SURVIVED while having actually been caught — it just reported under the outer host routes (authenticated) label. Digging in found another scaffolding trap: a large try block contained const bad = await call(...), and a block-scoped const bad puts every bad in that block into the temporal dead zone, so when the inner catch tried to call the reporting function it threw Cannot access 'bad' before initialization, which the outer catch took for a routing error. Renamed to badEscape and given its own try/catch, the two states finally lined up. Once again the scaffolding was lying, not the guard failing — so "a mutation survived" is itself a conclusion to doubt first.

The final judge is a real browser: everything above on the Node side is blind to the "rendering + input pipeline", so the last step of npm run check is scripts/browser-tests.js — it uses scripts/cdp.js (zero dependencies: Node 22 ships WebSocket) to launch the machine's Chrome/Edge and dispatches real mouse and keyboard events at a stub host (a real popout page assembled by scripts/build.js plus fake data/listdir/content endpoints), then asserts what the user sees: whether rows appear, whether a directory expanded, where the menu was drawn, and whether the file tree still holds its 280px with long names un-ellipsised when the divider is dragged to the limit. The three Office formats are verified here too: the stub host serves the real libraries (the files under src/vendor/ verbatim), while the fixtures are built on the spot when the test starts — .docx / .pptx are minimal hand-written OOXML packages (zipped with JSZip) and .xlsx is written by SheetJS itself, so the repository carries no binary samples that can go stale, and "the reader can open the fixture" is guaranteed by the library itself; the assertions land on the rendered text (Word's body and its second paragraph, Excel's first row and worksheet switching, PowerPoint's slide text and the 1 / 1 page number), plus "the four vendored assets really were requested" and "not one cross-origin request throughout". Where the machine has no Chromium-family browser it skips rather than fails; screenshots land in a temporary directory for a human to check — assertions can verify geometry and state, never "does it look right".

The first bug this layer caught: the popout page's context menu rebuilt every item on each mouseenter, so the button under the pointer was replaced the instant the pointer entered it, and Blink's answer to "the hover target was replaced" is to fire another mouseenter at the new node — an infinite rebuild hundreds of times per second (measured: 351 rebuilds while the pointer sat still on one item for 400ms). mousedown and mouseup then landed on two different nodes, and by spec the browser dispatches click to their nearest common ancestor (the menu container), so no menu item ever ran. The sidebar's React version never had this problem — the same keyed buttons are updated in place; the popout page is hand-written DOM, so the equivalent guarantee has to be supplied by hand: menu items are built once when the menu opens, after which moving the highlight only toggles an is-active class, and clicks and hovers are handled by one delegated listener on the container each. Reverting highlightTreeMenu() to "rebuild on hover" makes the browser test report the menu rebuilt itself while the pointer was over an item — expected 0, got 351 immediately.

One lie told by the test itself was fixed along the way: scripts/domstub.js's dispatch() originally fired only the target node's own listeners and did not bubble. A real browser does bubble (only stopPropagation() in an item handler stops it), so the delegated menu "did nothing when clicked" in the stub — so the stub gained bubbling and stopPropagation semantics, rather than writing non-delegated code to accommodate the stub.

Installing the pre-commit guard is recommended (once): ln -sf ../../scripts/precommit.sh .git/hooks/pre-commit. Every git commit afterwards runs the checks above, rebuilds the bundles when needed, and blocks the commit while they are stale. The hook passes --no-browser: the real-browser step is left for you to run with npm run check.


Theming

  • The sidebar panel follows the main interface's light / dark theme (using the --dsw-alias-* theme variables); on the native surface the system sidebar carries it and the panel drops fixed positioning, shadow and seams together, leaving only content; code, math and Mermaid diagrams in the preview render with the current theme as well.
  • The toolbar's and tab strip's background colours, the .5px divider and the selected underline all come from theme variables (the tab strip used to hard-code #fff, which showed as a white line in dark mode); the dark token palette for Markdown code blocks never took effect because a selector was missing a space, which is now fixed.
  • The standalone tab is fixed to a light palette (keeping content clear and stable on the other monitor); add ?scheme=dark to the address for dark, or ?scheme=auto to follow the system. Both sides' dark-mode detection matches the theme marker on :root and on body.

Updates

  • This plugin installs from GitHub source (it is not published to npm).
  • After new commits land, upgrade with dsh plugin --profile web update dsh-sidebar-frog — the CLI forwards it to pnpm update in the profile, which re-resolves the branch head and reinstalls (verified end to end in a scratch profile: an install sitting on one commit followed the branch to the next), or add the same repository address again. Then restart dsh web and hard-refresh the browser.

Did the restart take? Confirm with the build id

dsh web reads the host bundle once, at startup, and the popout page is served from the process's memory too; rebuilding the sources without restarting leaves the browser fetching the old page. So every build stamps the same digest onto all three artefacts (it changes on every build; the value below is an example):

npm run check                      # → built src/host.js and src/client.js · build 18c94cd7
# the log line when the process starts:  [artifacts] dsh-sidebar-frog build 18c94cd7
curl -s http://127.0.0.1:3080/dsh-sidebar-frog | grep dsh-sidebar-frog-build
#   → <meta name="dsh-sidebar-frog-build" content="18c94cd7" />

The two halves have different lifetimes, and that is the entire reason "sometimes you restart, sometimes you only refresh":

Half When it is read After a rebuild
Host half (routes, artifact capture, the popout page, the bundled pdf.js / MathJax / Mermaid / JSXGraph and the four Office readers) Once, when dsh web starts, and stays in memory Restart dsh web
Client half (the panel / the five views / the stylesheet) Fetched from disk on every page load (/plugins/??…&rev=<content hash>), and the system can hot-swap it (dsh-client-hmr) Refresh the page

The top of the settings panel now prints both halves' builds (客户端 build … · 宿主 build …) and says outright when they disagree: "the host half is still the old build: new routes and artifact capture will not take effect until dsh web restarts". All three agreeing is what proves the new code is really running.

A hot swap used to throw the panel back to "the original interface" — fixed. The system's hot swap is: let the old fiber's disposers finish → delete this plugin's <style data-plugin> → remount the new bundle. This plugin used to drop the disposer for its tab-type registrations (the value returned by sidebarRightTabs.register() was never handed back to the fiber), so the old registration outlived its own module: the new bundle registering the same id was rejected by the registry (that is its contract), the plugin rolled back every type as designed → fell back to the floating panel, and only restarting the process could recover. The same root cause had two quieter consequences: the Markdown / table / Office renderers lent to the system (plus the PDF one taken over by capability) quietly disappeared after a hot swap (the duplicate implementation was rejected), and the stylesheet, lacking data-plugin, stayed on the old version forever (so even a CSS change looked like it needed a restart).

The fix is the system's own pattern — every registration hands its disposer back to its own fiber (ctx.effect(...), exactly how the official ui-sidebar-files does it) — plus tagging the stylesheet with data-plugin="dsh-sidebar-frog" and allowing it to be replaced in place. The checks gained a section that really drives one hot swap (dispose() → apply() again): break the binding and it reports the sentence the user saw, a hot swap fell back to the floating panel: that is the 「原始的界面」….


Author

曾青松 (Zeng Qingsong) (GitHub: zengqingsong)

  • GitHub: https://github.com/zengqingsong
  • Email: pypzengqs@126.com
  • Repository: https://github.com/zengqingsong/dsh-sidebar-frog (issues / PRs welcome)

This plugin is a fork of e2mcc/dsh-popout-sidebar (MIT, Copyright (c) 2026 Qinyun Cai): the initial skeleton came from that project, and it has since evolved in offline rendering, the file tree, multiple tabs, the cross-window @-reference bridge, data-route authentication, undo and background jobs. The upstream copyright notice is kept in LICENSE.

If this sidebar is useful to you, a ⭐ is the best encouragement.


License

MIT · Copyright (c) 2026 Qinyun Cai (upstream dsh-popout-sidebar) · Copyright (c) 2026 曾青松 / Zeng Qingsong (this fork)

—/ 5

No ratings yet

Verified DSH bundle

Commit 75c86559ac38

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout