Skip to content
dsh.fish
Bundle

dsh-plugin-pi-ui

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.

Source
ice-ai-lab
License
MIT
Updated
Updated 6 hours ago

Readme

---
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 | [中文](README.zh.md)

## 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:

1. **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.
2. **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.
3. **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](#install)
- [Use this plugin](#use-this-plugin)
- [Understand the implementation](#understand-the-implementation)
- [Further exploration](#further-exploration)
- [Model experience](#model-experience)
- [Known limitations and deferred work](#known-limitations-and-deferred-work)
- [Dev note](#dev-note)

-----

<a id="install"></a>
## 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`.

```sh
# 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:

```yaml
# ~/.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:

```jsonc
// ~/.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.

<a id="use-this-plugin"></a>
## 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.

<a id="understand-the-implementation"></a>
## 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:

```css
[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 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.

<a id="further-exploration"></a>
## Further exploration

- [`@deepseek-ai/dsh-client-ui-slots`](https://github.com/deepseek-ai/deepseek-harness)
  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.

<a id="model-experience"></a>
## 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.

<a id="known-limitations-and-deferred-work"></a>
## 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.js` is read at boot; the browser half
  hot-reloads through `dsh-client-hmr` instead.
- **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.

<a id="dev-note"></a>
## 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.

```sh
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.

Install

dsh plugin --profile web add github:ice-ai-lab/dsh-plugin-pi-ui

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source