description: "pi-web-inspired sidebar chrome for the DeepSeek Harness Web GUI: temporary sessions at the top, a one-directory session list under a working-directory picker, a draft composer that creates nothing until you send, and a compact file explorer that takes over the right column's Files tab." kind: "package-reference"
dsh-plugin-pi-ui
English | 中文
Summary
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:
- Temporary sessions at the top. The newest disposable scratch sessions, five at a time with a 查看更多 footer that loads ten more per press. The + button starts a draft; the 🗑 cleans up scratch directories no session is using.
- The working directory below it, anchored at the column's middle. A compact dropdown picks the directory being worked in — and the shipped session list under it always shows that one directory's sessions.
- A compact file explorer in the right column, where its Files tab otherwise shows the shipped workspace-files view.
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.
Table of Contents
- Install
- Use this plugin
- Understand the implementation
- Further exploration
- Model experience
- Known limitations and deferred work
- Dev note
Install
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.
Use this plugin
The column at a glance
[ 临时会话 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.
Nothing exists until you send
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 session list always shows one working directory
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
Settings → General gains two switches:
- File explorer — the right column's explorer, in place of the shipped file view. Turning it off restores the shipped view immediately, with no reload.
- Working directory switcher — the plugin's sidebar chrome as a whole.
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.
Understand the implementation
Why the chrome is an overlay
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-wideon 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-focusplus a one-rule stylesheet shows exactly one session group, described below.data-dshpi-header-controlskeeps the CSS hook meaningful while the header row has no plugin-drawn control.
Every other shipped element is left alone.
The one-directory session list
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.
The draft composer
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:
- the input box is measured from
[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; - the send control is measured from the card's primary button and covered by a live twin of the same size, radius and fill.
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.
Start gestures and the pending target
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.
The explorer as a right-column tab type
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.
The node half
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.
What is persisted
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.
Further exploration
@deepseek-ai/dsh-client-ui-slotsand the renderer's seat semantics — what asingle,list,keyed, orchainseat 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-browseplus 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.
Model experience
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:
- A temporary session's working directory is
<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. - The session list, the picker, the draft composer, and the explorer are all UI-only surfaces; none of them writes to a session log, and none of them reaches a model.
- The node half's three routes are HTTP, not tools, and are never offered to a model.
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.
Known limitations and deferred work
- The shipped row stays the shipped row. In the blank state the shipped permission and model seats are inert, because the shipped conversation only activates them for a session that has a working directory. The plugin makes the input and the send control live and deliberately does not restyle or replace the rest of the row.
- The draft target is in-memory. Reloading the page before the first send drops the draft; nothing was created, so nothing is lost but the typed text.
- Removal is registry-only, by design. Remove working directory… never deletes a directory or a session log.
- Node-half changes need a restart. The shipped web profile boots with the
host HMR plugin disabled, so
lib/index.jsis read at boot; the browser half hot-reloads throughdsh-client-hmrinstead. - One directory at a time, always. There is no "show every directory" state any more; that is the point of the picker, but it also means a directory with no sessions shows an empty region until you switch away.
Dev note
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.
No comments yet. Be the first to write one.