dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
English | 中文
This package reshapes the Web GUI's left sidebar into blocks that read as one
system, riding the harness --dsw-* design tokens so it follows every shipped
theme:
Cutting across all three: nothing exists until you send. Every "new session" gesture opens a draft composer and stops there. No scratch directory is minted and no session row appears until the first message is actually sent — then the directory and the session are created together, or an existing provisional blank session in the named directory is reused.
The plugin is a plain out-of-tree package: a node half, a browser half, and a
loader patch. Add it to a profile and restart dsh web.
# From a profile with this package available:
dsh plugin --profile web add dsh-plugin-pi-ui
The package declares dsh.bundle.patch, so the command above appends it to
dsh.profile.bundles and its own cordis.patch.yml applies on the next boot.
The equivalent hand-written row goes in the profile's own patch layer:
# ~/.dsh/profiles/web/cordis.patch.yml
- insert:
- id: pi-ui
name: 'dsh-plugin-pi-ui'
Use exactly one of the two routes: the same row id inserted twice fails boot. For a local checkout, a link dependency keeps edits live across restarts:
// ~/.dsh/profiles/web/package.json
{
"dependencies": {
"dsh-plugin-pi-ui": "link:/absolute/path/to/dsh-plugin-pi-ui"
}
}
A restart of dsh web is required. The browser half is composed into
window.__DSH_BOOT__ at boot, so a newly added client plugin cannot appear in an
already-running server even though its node half loads live. After restarting,
reload the page.
To remove it again, drop the row from dsh.profile.bundles (or the profile
patch), remove the dependency, and restart. Scratch directories stay on disk;
remove <DSH_HOME>/scratch/ when you no longer want them.
[ 临时会话 1 🗑 + ]
⋮ (the column's middle)
[ 工作区 [ 📁 …/my-project ▾ ] + ]
[ that one directory's sessions … ]
The shipped full-width New Session button, the search magnifier, and the add-workspace button stand down while the column is wide; the plugin supplies the controls in the table below. In the collapsed rail the plugin draws nothing and every shipped control returns.
| Control | Gesture |
|---|---|
| + in the 临时会话 header | Opens the draft composer for a temporary session. The click creates nothing; sending mints the scratch directory and the session together. If the session cannot be created, the fresh directory is deleted again, so no orphan folder is left behind. |
| 🗑 in the 临时会话 header | Deletes scratch directories that no session is rooted in (shown only when one exists). It confirms first and never touches a directory in use. |
| the dropdown between 工作区 and its + | The working-directory picker: your directories, Open folder…, and Remove working directory…. Picking a directory with sessions opens its provisional blank session, else its newest; picking one with no sessions yet hands you the draft composer instead of minting a session. |
| + at the 工作区 row's right edge | The draft composer for a new session in the directory shown. The click creates nothing; the first message creates the session, reusing that directory's provisional blank session when one is already there. |
| Remove working directory… | Lists every registered directory with its session count and removes the one you pick. Removal touches the registry alone: the directory, its files, and its session logs all stay put — those sessions simply stop being grouped under it. |
A temporary session row re-opens that scratch session. Temporary sessions are deliberately absent from the dropdown — that control stays a directory picker — and they are never registered as workspaces, so they do not accumulate in the durable workspace registry.
Every start gesture ends in the same place: a draft composer instead of a new
session. That is what keeps the sidebar free of "新会话" rows for sessions nobody
ever wrote in, and it is why a temporary session's directory only appears under
<DSH_HOME>/scratch/ at the moment you send.
The draft composer is the shipped blank-state card with two live twins laid over it: an editable input box over the card's own input area, and a send control over the card's (inert) send circle. Everything else about that card is the shipped card, drawn by the shipped component: the dashed border, the attach control, the permission select, the model seat, and the send button's own look. The plugin adds no hint line and no controls of its own. Type, then press Enter or the send circle.
The region below 工作区 is a working directory's session list, and it shows exactly one directory's sessions at a time — the directory the picker shows: the current session's own workspace when it has one, else the one you last chose. A temporary session carries no working directory, so it neither changes nor appears in that list. There is no toggle and no "show everything" state: the picker is the selector.
The picker belongs to you, not to the selected session. The directory you chose last is remembered in the browser, so moving in and out of temporary sessions never blanks it out. Searching (the section header's magnifier) still searches every session, whatever the list is showing.
Settings → General gains two switches:
Both are in-memory: they reset on reload. That is deliberate — a persisted preference would want the settings service rather than a client-local store.
The shipped sidebar declares no seat above its browsing region, and the only
seats inside its brand row (sidebar.brand.mark / sidebar.brand.name) are
single cells a shipped package already occupies — registering there would
shadow the product brand, and both sit inside a <button> that starts a
session. So this plugin registers into shell.overlay, the frame-wide
additive list seat, and positions its surfaces over the sidebar column.
A seat is given no geometry, so the plugin measures. It resolves the column from
its semantic CSS-module class suffixes (the build keeps them: the runtime class
is <hash>_regionArea) and writes one custom property onto it:
[class*="regionArea"] {
padding-top: var(--dshpi-reserve-top, 0px);
}
The shipped region reserves that band, so the temporary list sits above the list's own content instead of covering it. Three further marks are deliberate and narrow:
data-dshpi-wide on the column — only while the column is wide — hides the
shipped full-width New Session button, the header actions, and the search
button, and reserves the brand's right padding so the row's own text stays
clear of the controls that replace it. The collapsed rail keeps everything.data-dshpi-focus plus a one-rule stylesheet shows exactly one session group,
described below.data-dshpi-header-controls keeps the CSS hook meaningful while the header row
has no plugin-drawn control.Every other shipped element is left alone.
The browsing region is a single seat that ui-workspace already occupies and
its group rows carry no workspace identity, so the plugin cannot filter the
list's data. What it can rely on is the list's own order:
groupByWorkspace emits one group per workspace in Host order and appends the
ungrouped bucket last, so a group's position is its workspace's position.
Marking the column and revealing exactly that one position with an nth-of-type
rule is declarative and immune to a React re-render undoing it.
The focused group's folder header is hidden while it is expanded, so the region reads as a plain session list rather than a folder with one child. That hiding is conditional on the group being expanded because a folded group keeps its header as the only control that can unfold it — and since the fold state is persisted and there is no toggle to escape with, leaving that control in place is what stops a collapsed group from stranding a directory with no way back. That same reasoning is why the dropdown carries Remove working directory…: with every other group hidden and the focused header hidden while expanded, the shipped rename/delete gestures are out of reach.
In the blank state the shipped card is inert: ui-conversation only activates it
for a session that has a workspace (its own chip title), and the shipped model
seat is not even rendered without a session. The plugin therefore twins the two
controls a draft needs and leaves the row alone:
[data-composer-card] /
[data-composer-input] and covered by a transparent-bordered textarea filled
with the card's own --dsw-specific-input-major (so the shipped placeholder
underneath cannot show through), using the card's own font size, line height
and caret color;Measurement is one rAF-coalesced pass fed by a 400 ms poll, a window resize listener, and a body-subtree MutationObserver, so the twins follow the hero's entrance animation, a sidebar collapse, and a window resize.
A start gesture sets an in-memory pending target and clears the current selection, which is what puts the draft composer on stage:
| Target | Created on send |
|---|---|
scratch (the temporary +, the default) | POST /dsh-pi-ui/scratch mints a directory under the scratch root, then the session is created with that cwd. A refused session creation deletes the fresh directory again. |
workspace (the workspace +, a directory with no sessions) | The session is created with that workspaceId — nothing is written to disk. An existing provisional blank session there is adopted instead of duplicated. |
cwd (a session-less directory the plugin can see) | The session is created with that cwd. |
The target lives only in memory and is retired as soon as any session opens, so a reload before the first send simply drops the draft.
ctx.sidebarRightTabs.register(...) claims the shipped file view's kind — the
files kind — at extension priority, the band a type from outside the product
may use to take a builtin kind over, and the keyed sidebar.right.pane.tab seat
supplies the body under this type's own id. A guide entry keeps the column's
"new tab" page listing it, and because that entry replaces the builtin's, the
column's default page is still the same kind.
The tree is rooted at the current session's working directory — for a temporary session, its scratch directory — and lists one level at a time through this package's node half. Clicking a file opens it through the harness resource address, so it lands in the right column exactly as the shipped tree's rows do. Turning the Settings switch off unregisters the type and the shipped view resumes, with no reload.
Three plain exact HTTP routes, because an out-of-tree plugin has no codegen
step for a generated Remote namespace:
| Route | Method | Bounds |
|---|---|---|
/dsh-pi-ui/tree | GET | Read-only, direct children only, resolved through the harness fs backend, refused when the resolved target escapes the session working directory, capped at 800 entries. Never reads file contents. |
/dsh-pi-ui/scratch | GET / POST | POST takes no caller input: the directory name is generated server-side, under the scratch root only. |
/dsh-pi-ui/scratch/delete | POST | Accepts names only. Each must be a single path segment, is re-checked for containment after resolution, and the scratch root itself is never a deletion target. Caps 200 names and 64 KiB per request. |
Like the rest of the plugin surface in this ecosystem, these routes are not behind the browser-session cookie. Treat them as local-only and do not expose the server beyond loopback.
Only one client-local key: dsh-pi-ui.workspace, the working directory the
picker last showed. Everything else — the pending target, the two Settings
switches, the measured geometry — is in memory. Temporary sessions need no
bookkeeping at all: they are recognised by matching a session's working
directory against the canonical scratch root.
@deepseek-ai/dsh-client-ui-slots
and the renderer's seat semantics — what a single, list, keyed, or
chain seat promises a registration.@deepseek-ai/dsh-client-ui-sidebar-right — the tab registry this plugin's
explorer registers into, including the priority bands.@deepseek-ai/dsh-client-ui-workspace — the browser whose session groups this
plugin scopes one at a time.@deepseek-ai/dsh-host-directory-picker-browse plus its client half — compose
them to replace the OS folder chooser with pi-web's in-app browser. The
plugin's Open folder… tries the in-app primitives first and falls back to
the OS chooser, so no plugin change is needed.dsh --profile web --dump-config — the composed profile tree, the fastest way
to see which bundles and patch layers are in play.The plugin registers no tools and no commands, so a model sees only what the harness already gives it. What this package changes about that:
<DSH_HOME>/scratch/<YYYYMMDD-HHmmss>-<4 hex>/, created at send time. The
ordinary sandbox policy scopes the session there exactly as it scopes any
other session.Safety note. The scratch root lives inside the harness home, which also
holds credentials. A session rooted under it is scoped by the ordinary sandbox
policy like any other, but if your policy lets a session read outside its
working directory, a temporary session can read its ancestors under
<DSH_HOME>. Point DSH_HOME elsewhere, or prefer explicit working
directories, if that matters to you.
lib/index.js is read at boot; the browser half
hot-reloads through dsh-client-hmr instead.lib/index.js node half: the three routes above
lib/client.js browser half: the chrome, the draft composer, the explorer tab
cordis.patch.yml the loader row this package contributes
README.md this file
README.zh.md the Chinese side
README.i18n.yaml the bilingual-pairing record (git blob hashes)
lib/client.js is a hand-authored bundle in the client module system's built
format (window.__ModuleLoader__.load({ id, factory })). It requests only
platform seed modules — react and @deepseek-ai/dsh-client-ui-primitives — so
no dsh.client.external is declared and the whole plugin is two script files
with no build step.
npm run check # node --check on both halves
A crash inside one surface is contained: the switcher and its dialogs sit behind their own error boundaries, so a failure in one does not unmount the rest of the chrome, and the explorer tab's registration is guarded step by step so a right-column wiring mismatch costs the explorer rather than the sidebar.
The README pair is kept in step by hand; README.i18n.yaml records the blob
hash of each side, and both files are edited together or not at all.