English · 简体中文
@dsh-xhl/workspace-groups
A DeepSeek Harness (DSH) web client plugin: a complete workspace grouping manager.
Turns the GUI sidebar's two-level workspace list (Projects → Sessions) into a
three-level Category folder → Project folder → Session tree, backed by full
group-management capabilities: manual group creation, rename/delete of any group,
drag-and-drop grouping, free ordering of projects and groups, rule-based
auto-classification, and tree-shaped search. Every action takes effect
immediately and persists, with zero intrusion on official data.
Screenshot
@dsh-xhl/workspace-groups workspace grouping manager
Features
Grouped tree browsing
- Group folder → project folder → session row, both levels collapsible; expansion state
persists independently (
dsh.workspace.groups.view.v1, survives refresh/restart)
- Top-level project rows: ungrouped projects (matching no rule, dragged out of a group,
or returned by a group delete) render as plain rows right after the group folders, at the
same level — there is no "未分类" bucket
- A "flower branch" runs down the tree and grows to the row you are on. Ported from the
RewampUI Flower Sidebar: one continuous
SVG stem behind the rows, with every dot anchored to its own row's marker — a folder's dot
sits on its expand triangle, a session's on its status dot — so the three levels form three
clean columns instead of one stem floating beside them. A faint dashed ghost draws the whole
route while a solid stroke is drawn only as far as the active row, so the stem visibly reaches
where you are; that row's dot blooms (r 3.2 vs 2). Both coordinates are measured per row
(the tree mixes row heights and indents per row via padding, so neither a single pitch nor a
single stem x lands correctly). The overlay is
pointer-events: none and paints behind the
rows, so every click, hover and drag still belongs to the row. The host theme has no accent hue
to borrow (--dsw-alias-brand-primary is the same near-black as the label colour, and the only
saturated tokens are the state colours, which would falsely read as "running" / "needs
attention"), so the bloom is expressed by size plus full-strength ink instead.
- You can tell at a glance which workspace a session belongs to. Beyond the branch, three
more independent cues — not indent alone, since a 20px indent with identical type at both
levels was previously the only difference between a child session and a sibling project row:
- Weight: folder labels 600 (700 for the folder holding the open session); session titles
400 (500 when that session is open).
- Typeface: folder names are directory names and render monospace; session titles are
prose in the UI font. This cue is not colour-based, so it survives every theme.
- Colour: folder labels primary, session titles secondary — the open session lifts back.
Group management (full lifecycle)
- Create groups manually: the "新建分组" button in the section header shows the group
immediately (empty groups render too)
- Rename / delete any group: every group row (rule categories included) has a hover
⋯ menu; deleting a group sends all of its projects back to the top level;
rule-category rename/delete rides the overlay (renamed / hidden),
the rule YAML stays untouched
- Rule-based auto-classification: the sidecar YAML declares category rules (
pathPrefix /
pathExact / nameContains / basenameContains); edit the config to adjust grouping
without touching code
Drag-and-drop grouping + ordering
- Drag projects into groups: drop on any group row or on a project row inside a group
(cross-group move = overrides the rule classification)
- Drag projects OUT of a group: the entire top-level area is the move-out drop
target while dragging, shown with an insertion line (not a highlight box) — drop on
any top-level row (reorder before/after it), on the blank space below the last row
(append), or, when the top level is empty, a standalone line under the last group
folder; grouped projects also have a "移出分组" menu item (rule-classified ones included)
- Reorder projects inside a group: top half of a project row = insert before it,
bottom half = insert after it
- Reorder top-level projects: top-level rows are draggable too — top half = insert
before, bottom half = insert after; the top-level order persists under
workspaceOrder["__topLevel__"]
- Reorder groups: group rows are draggable — top half of another group row = move before it,
bottom half = move after it
- Insertion position indicator: a 2px line (above/below the row) shows the exact drop
point while dragging — what you see is where it lands
- Level-aware folding, auto-restored: dragging a project folds every project row
(grouped AND top-level; group rows stay expanded); dragging a group folds every group
(project rows keep their expansion) — dragend restores the pre-drag expansion snapshot
- Distinct row icons: group rows use a folder glyph, project rows a project glyph
(same as the official workspace browser) — groups and projects are easy to tell apart
Search & operations
- Tree-shaped search: results keep the three-level structure (category → project → matched
session), matched rows highlighted with a content snippet, 250ms debounce
- No regression on workspace/session actions: Add Workspace, project rename/delete,
session new/open/rename/fork/archive
- Open in folder: the project row menu gains an "Open in folder" entry whenever the
host resolved a system file manager (reusing the official open-in-app routes — this
plugin adds no host route of its own)
Session tabs
- A tab means "in play", not "once opened". A session earns a tab only once a conversation
has actually started in it and it is still engaged: running, waiting on you
(approval / plan review / question), or finished while you were away (the unread marker —
the tab is what tells you to come back). A blank draft holds no tab until its first message is
sent, and browsing settled history leaves the strip alone — clicking through finished
conversations no longer litters it with tabs. Tabs already opened stay open; this rule only
decides whether a new one appears. (Browsing history is the sidebar's job; the strip is a
working set. The earlier
!blank rule failed because a conversation finished last week is
also non-blank.)
- Each tab is shaped like a folder tab: the workspace on a short top line over the session
title on a longer bottom line, so the stacked pair reads as a labelled folder rather than two
unrelated strings. The workspace line is monospace, smaller (10px) and secondary; the title is
12px and primary, making the conversation the chip's subject. Each line sizes to its own content
(
align-items: flex-start), which is what produces the short-over-long shape. Chips carry a
faint inset outline that strengthens on the active one, so a row of them reads as separate
objects instead of one continuous bar. A session in no workspace shows its title alone,
vertically centred. The tooltip carries the full workspace · title plus the directory path.
- The strip is kept on the HOST, and survives a reload, a restart and a port change. It is
stored in a JSON sidecar under
$DSH_HOME (workspace-groups.tabs.json), served over
GET|PUT /workspace-groups/tabs. Browser storage cannot do this job: the origin includes
the port, every launch picks a new one, and localStorage / sessionStorage are both
scoped to the origin — so a tab list kept there starts empty next time. The read is deliberately
not gated by any browser-session marker: a marker has to live in browser storage too, so the
port change would erase it and every restart would look like a fresh session (that bug shipped
once and silently discarded the user's tabs). The sidecar simply is the strip's memory, and a
file written by the older marker-based build still loads — the extra field is ignored.
- A tab appears the moment a session starts working: a sidebar session row, a workspace's
+ (New Session), the Ctrl+R quick switch, or a conversation-header crumb — open a session
from any entry point and, once it starts running, is waiting on you, or finished while you
were away, a tab appears in the strip at the ; opening
another adds another (one tab per session)
Persistence & zero intrusion
- Every manual action (groups, grouping, ordering, rename, hide) is written to the plugin's own
overlay (
~/.dsh/workspace-groups.manual.json), validated by the host and written
atomically (a malformed write returns 400 and keeps the previous file)
- Zero intrusion: never touches
~/.dsh/storages/workspace.json, session on-disk
structures, or the official @deepseek-ai/dsh-client-ui-workspace package; the rule YAML
is never rewritten
- Self-contained artifact:
lib/ is prebuilt and shipped with the repo — installing from
Git runs no dependency scripts
How it works
- The plugin is a client plugin with ONE slot registration plus one DOM-seated view:
- the sidebar browser, registered into the official sidebar shell's
sidebar.workspaces
slot (kind: 'single', scope: 'root') at priority: -1, replacing the official
WorkspaceBrowser (registered at priority 0; lowest priority wins in a single slot);
- the session tab strip, which is not a slot occupant. It renders through a React
portal into a container injected as the first child of the conversation column
(
centerCol in ui-layout's AppFrame) — the row directly above the conversation panel.
- Why a DOM seat instead of a slot: the conversation column declares no child slot.
Its only child is the
main slot's occupant (the Conversation or a global panel), so there
is nowhere to register a strip. The nearest shipped slot,
conversation.composer.dock, is the band under the composer card — a different place
entirely. The strip therefore claims a real DOM node: tab-seat.ts finds the column by the
_centerCol class suffix the CSS-Modules transform always preserves (never the build-hashed
prefix), prepends its own <div>, and re-inserts that same element if React reconciles the
column's child list. A MutationObserver plus a bounded requestAnimationFrame retry covers
the column mounting after the plugin's apply() runs; both are torn down with the effect.
- One handle, one scope: the DOM seat is also what keeps the tab store legal. DSH's slot
registry enforces one handle, one scope — mounting one store handle under both
sidebar.workspaces (root) and a session-scoped slot (session) throws and aborts the
whole client apply(). Because the strip is a child of the browser entry rather than a
second registration, the store is seated exactly once.
- Who owns the strip: tabs are a view, not a second navigation model. The tab
lifecycle (which sessions hold a tab, in what order) belongs to the sidebar browser — it
already derives the open session for its own tree, so "does this session need a tab?" is
decided in exactly one place (and the effect is idempotent per session). The strip only
renders and supplies the navigation verbs, receiving the tab store, the session hooks and
the navigation callbacks as ordinary props from that one component.
- All data comes from the runtime API: the
useWorkspaces / /
global hooks and / /
— grouping is purely a .
Install (npm)
Prerequisite: DeepSeek Harness
installed (dsh available) with a target profile initialized (e.g. the built-in web).
Version pairing: 0.3.x of this plugin targets the dsh 0.1.7-rc.2 client
contract (useSessionStatus / ctx.uiWorkspace / the mainView selection
model); on a 0.1.2-rc.1-era dsh, use 0.1.2.
0.3.0 note: dsh 0.1.7 renamed the whole dsh-client-ui-primitives icon set
from size suffixes to weight suffixes (IconCloseFill14 → IconCloseFillMedium /
…Regular, no legacy aliases); every call site now uses the new names directly.
dsh plugin --profile web add @dsh-xhl/workspace-groups
This automatically:
- Adds
"@dsh-xhl/workspace-groups": "^0.3.0" to dependencies in
~/.dsh/profiles/web/package.json
- Appends
"@dsh-xhl/workspace-groups" to dsh.profile.bundles
- Runs pnpm install and validates the bundle layer
Restart the web profile after installing (both the bundle and the host half only load
on restart):
# Stop the running dsh web process and start it again, e.g.:
dsh web
Verify the install:
dsh --profile web --dump-config | grep -A3 workspace-groups
# expect: - id: workspace-groups / name: @dsh-xhl/workspace-groups / config: {}
curl http://127.0.0.1:3080/workspace-groups/config
# expect: the sidecar YAML parsed as JSON
Alternative: install from GitHub
dsh plugin --profile web add github:z-col/dsh-workspace-groups
This adds "dsh-workspace-groups": "github:z-col/dsh-workspace-groups" (pinned to
version/commit) to dependencies instead. The GitHub distribution works from the
committed lib/ artifacts and requires no publish step.
Uninstall
dsh plugin --profile web remove @dsh-xhl/workspace-groups
This removes the dependency from dependencies and the matching line from
dsh.profile.bundles. A web profile restart is required for it to take effect.
Manual equivalent (pick one, don't repeat): edit ~/.dsh/profiles/web/package.json,
remove the @dsh-xhl/workspace-groups line from dependencies and
"@dsh-xhl/workspace-groups" from dsh.profile.bundles, then run pnpm install in
that directory.
Classification config (sidecar)
Default location ~/.dsh/workspace-groups.yaml (override the home dir with the
$DSH_HOME env var). Template: workspace-groups.example.yaml at the repo root.
categories:
- name: DSH 插件
rules:
- pathPrefix: /Users/zcol/Project/SkillsManagePlugins
- nameContains: 插件
- basenameContains: plugin
- name: 个人项目
rules:
- pathPrefix: /Users/zcol/Project/yeluzi
Rule fields (each rule is an OR — any match classifies; categories are matched in order,
first match wins):
| Field | Meaning |
|---|
pathPrefix | Project absolute path prefix |
pathExact | Project absolute path exact match |
nameContains | Project display title contains (case-insensitive) |
basenameContains | Project directory name contains (case-insensitive) |
Projects matching no category — or moved out of a group — render as top-level project
rows (same level as the group folders), never hidden.
Manual groups & drag-and-drop grouping (runtime overlay)
Besides the rule YAML there is a plugin-owned runtime overlay, recording only manual
UI operations, at $DSH_HOME/workspace-groups.manual.json (e.g. ~/.dsh/workspace-groups.manual.json):
{
"categories": ["临时", "归档"],
"assignments": {
"a1b2c3d4-e5f6-7890-abcd-ef1234567890": "临时",
"a1b2c3d4-e5f6-7890-abcd-ef1234567891": null
},
"categoryOrder": ["临时", "DSH 插件"],
"workspaceOrder": { "临时": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"] },
"renamed": { "DSH 插件": "插件集" },
"hidden": ["文档"]
}
categories — manually created group names (no rules; empty groups render too).
assignments — workspace → group classification overrides keyed by the stable workspace id
(renames don't affect it). Takes precedence over YAML rules; a value of null means
forced top-level (even when a rule would match).
categoryOrder — group display order (top-level rows are not listed here; they always
render after the group folders).
workspaceOrder — per-group manual ordering of projects (written by drag ordering).
renamed / hidden — UI rename/delete of rule categories (a hidden category's rules become
inert and its matches go top-level); the rule YAML stays untouched.
- The file is written in full by the browser UI (
PUT /workspace-groups/manual, atomic
replace); manual edits also take effect on next load. A malformed write returns 400 and
keeps the previous file — the rule YAML is never at risk.
| Action | How |
|---|
| Create group | "新建分组" button in the section header (folder icon), enter a name in the dialog |
| Rename/delete group | hover ⋯ menu on any group (rule categories included); deleting sends its projects back to the top level |
| Drag project into group | drag a project row onto a target group row / any project row inside a group, release to move |
| Reorder projects | drag a project row onto another project row in the same group: top half = insert before, bottom half = insert after (indicator shows the spot); all project rows fold while dragging and restore on dragend |
| Reorder top-level projects | drag a top-level row onto another top-level row: top half = insert before, bottom half = insert after; order persists under workspaceOrder["__topLevel__"] |
| Move out of a group | drop anywhere on the top-level area (an insertion line shows the spot — reorder before/after a top-level row, or append below the last row; when the top level is empty a line shows under the last group), or the project row's "移出分组" menu (forced top-level) |
| Reorder groups | drag a group row onto another group row: top half = move before, bottom half = move after (indicator shows the spot; all groups fold while dragging, restored on dragend) |
| Open in folder | the project row's "在文件夹中打开" / "Open in folder" menu entry — opens the project directory in the host's file manager (see below) |
Open in folder (reuses the official open-in-app)
The project row's "Open in folder" entry carries no launching logic of its own: it
reuses what the official @deepseek-ai/dsh-host-open-in-app already resolved —
GET /open-in-app/apps reads back the applications that host verified as launchable,
the plugin picks this platform's file manager from them
(Windows explorer / macOS finder / Linux filemanager), and POSTs the project
directory to POST /open-in-app/open for its verified launcher to handle.
- When that host half resolved no file manager (e.g. a remote SSH deployment),
the entry is simply absent rather than failing on click; a project whose
directory no longer exists is refused by the host with 404 and surfaces as a
transient "could not open the folder" banner.
- This plugin's own host half is therefore unchanged: no new route, no process
spawn — permissions and the Host/Origin fence stay with those three official routes.
Topics
This repo targets automatic discovery by the DSH plugin ecosystem (community marketplaces
scan GitHub topics). Already set:
dsh-plugin (core tag; 1024Store
and similar marketplaces discover by this topic periodically, validating
package.json + the plugin bundle manifest (cordis.patch.yml))
deepseek-harness / deepseek-harness-plugin / dsh
sidebar / workspace / workspace-groups
package.json also provides keywords for npm/search indexing.
Development
pnpm install
pnpm typecheck # host + client dual-program type checking
pnpm test # core rules, overlay, tree derivation unit tests
pnpm build # build lib/ (node half + client bundle)
pnpm watch # tsdown watch (client HMR)
node scripts/verify-groups.mjs # real-browser CDP verification (host restarted; self-spawns a headless Chrome, auto-restores the scene)
Artifact contract (mirrors the official client packages):
lib/index.js — host half (ESM; reads the sidecar + /workspace-groups/config route;
js-yaml inlined, no runtime dependencies)
lib/client.js — browser half (window.__ModuleLoader__.load({id, factory}); only
requires platform seeds: react / react/jsx-runtime / react-dom /
@deepseek-ai/dsh-client-store / @deepseek-ai/dsh-client-ui-slots /
@deepseek-ai/dsh-client-ui-primitives; cross-plugin value imports are rejected at build
time by the purity gate)
lib/types/** — declaration files
Release strategy: lib/ build artifacts are committed (no prepare script), so
dsh plugin add github:... never runs third-party build scripts — install and use.
Repository layout
src/
index.ts # host half: config snapshot route + manual write route + tabs route
host-config.ts # sidecar YAML reading/validation
host-manual.ts # runtime overlay read/write/validation (atomic publish)
host-tabs.ts # session-tab sidecar: atomic write + browser-session lifetime
context-types.ts # host-side cordis service structure types
core/
types.ts # config types (shared by both halves)
matcher.ts # classification + manual override priority + ordering pure functions (shared)
client/
index.ts # apply: registers sidebar.workspaces (priority -1); seats the tab store once
contract.ts # injected surface types (browser) + the strip's plain props
stores.ts # expansion-state store (persist: dsh.workspace.groups.view.v1)
tab-store.ts # session-tab store (hydrated from the host)
tab-persist.ts # client side of /workspace-groups/tabs (host-backed strip)
tabs.ts # pure tab state: append/close/restore/indicator derivation
store-hook.ts # selector hook over a store instance (the framework binds this for slot seats only)
tab-seat.ts # DOM seat: injects the strip container as the conversation column's first child
tree.ts # three-level tree derivation + tree search derivation
branch.ts # flower-branch geometry: the curly stem, its kinks, and the dots
GroupsBrowser.tsx # browser region component (group dialogs + drag grouping/ordering + insertion indicator)
SessionTabs.tsx # session tab strip component (portalled into the column-head seat)
FlowerBranch.tsx # SVG overlay that grows the stem to the active row
rows.tsx # category/project/session/search-result rows (drag sources/targets)
open-folder.ts # "open in folder" transport (client of the official open-in-app routes)
locales.ts # zh/en copy
styles.css # inline styles (browser + tab strip)
tests/
core.test.ts # classification rules + override priority + moveBefore/moveAfter + config parsing
manual.test.ts # overlay validation + atomic file round-trip
tree.test.ts # tree derivation rendering contract (manual group empty render / override priority)
store.test.ts # expansion semantics (collapse writes false, never deletes the key)
quick.test.ts # quick-panel data shaping (running first / workspace search ranking)
tabs.test.ts # tab state (append, close, restore, blank drafts, indicators, no sweep)
tab-seat.test.ts # DOM seat (first-child placement, late column mount, re-seat after React removes it, dispose)
branch.test.ts # flower-branch geometry (kink per group, dot per row on its own y, grown stops at active)
host-tabs.test.ts # host tab store (atomic write, no session gating, legacy-file compat, corrupt fallback)
styles.test.ts # tab-strip layout contract (chips do not shrink, row scrolls, scrollbar hidden)
open-folder.test.ts # open-in-app transport (file-manager pick + read/POST contract)
scripts/
verify-groups.mjs # real-browser CDP verification (self-spawns headless Chrome, auto-restores the scene)
Development docs (docs/ five-level framework and AGENTS.md) are engineering files for
development, not shipped with the repo (excluded via .gitignore).
Verification record
- v0.1/v0.2 real-combination verification (headless Chrome + CDP): three-level tree takeover,
correct classification, expansion persistence, search keeps workspace membership;
workspace.json / session on-disk / official store: zero intrusion.
- v0.3 real-browser verification 24/24 (
scripts/verify-groups.mjs: create group / drag /
order / collapse / rule-category menu / rename / delete-back-to-top-level /
scene restore; zero-intrusion assertions).
- v0.4 real-browser verification 30/30 (added: insertion indicator, project/group
downward drag (bottom half → insert after the target), group upward drag (top half →
move before the target); scene restored).
- v0.4.1 real-browser verification 34/34 (added: dragging projects OUT of a group —
top-level drop zone / top-level rows = forced top-level; grouped projects get the
"移出分组" menu item).
- v0.5 real-browser verification 35/35 (model change: no "未分类" bucket — top-level
project rows, group delete returns members to the top level, drag/menu move-out to the
top level, no uncategorized bucket anywhere in the tree; scene restored).
- v0.6 real-browser verification 40/40 (added: level-aware folding — dragging a project
folds project rows only (group rows stay open), dragging a group folds group rows only;
dragend restores the pre-drag expansion snapshot).
- v0.6.1 real-browser verification 42/42 (added: the whole top-level area as the move-out
drop target with a visible landing highlight; distinct folder-vs-project row icons;
scene restored).
- v0.7 real-browser verification 46/46 (added: top-level landing shown with an insertion
line instead of a highlight box — reorder before/after a top-level row, append below
the last row, or a standalone line when the top level is empty; top-level projects are
reorderable with their order persisted under
workspaceOrder["__topLevel__"]; also
fixed a host validation bug that rejected __topLevel__ and a drop-positioning bug where
dropping between two top-level rows landed above the first; scene restored).
- v0.8: open in folder on the project row, reusing the official open-in-app host routes
(per-platform file-manager pick, entry absent when the host resolved none, launch
failures surfaced as a transient banner).
- v0.9: session tabs — one strip at the top of the conversation column, injected as the
column's first child (the column declares no child slot, and the nearest shipped slot sits
under the composer card instead); clicking a session opens a tab, tabs close individually,
the status dot reuses the sidebar vocabulary, double-click renames, blank drafts stay out,
and the order persists.
- v0.9.1: rebuilt for — the client contract moved the icon set from
size-suffixed names () to weight-suffixed ones ( /
), and the tab store now mounts under a single scope (the slot version mounted one
handle under both and , which the registry rejects).
License
MIT