Bundle
dsh-workbuddy
Wordless-styled skin for the DSH blank-session hero: the brand tile and the workspace picker menu, with the native composer untouched.
- Source
- ml020
- License
- MIT
- Updated
- Updated 2 days ago
Readme
# dsh-workbuddy
Wordless-style skin for the blank-session hero of [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`).
Restyles the two hero slots — the **brand tile** and the **workspace picker menu** — with the warm, minimal look of [Wordless](https://github.com/Austin-Patrician/Wordless). The native dsh web composer (the dialog box) and the native picker chip are **never touched**.
<img width="100%" alt="light" src="./docs/preview-selected.png" />
## What it changes
| Slot | Kind | What you get |
| --- | --- | --- |
| `conversation.hero.brand.mark` | `single` | Lime gradient tile with the WorkBuddy `W` |
| `conversation.hero.workspace` | `single` | Wordless-styled menu, anchored to the host's chip |
| ↳ `conversation.hero.workspace.directoryFlow` | `single` | Child hole this entry declares, so directory pickers still work |
| `conversation.composer.bar` | — | **Untouched** — the original dsh dialog stays |
Both target slots are `kind: 'single'`, so the plugin uses `ctx.slots.inject` to supersede the shipped occupants rather than adding alongside them. Each slot is injected independently — `inject` waits for its slot to be declared, so chaining would couple the workspace occupant to the brand-mark slot's lifetime.
### How the picker slot works
`conversation.hero.workspace` is **not** a welcome panel. The host (`ConversationRoot`) renders the trigger chip itself and owns the open state; the slot occupant is only the menu body, and it receives:
| Prop | Meaning |
| --- | --- |
| `open` | Whether the picker is expanded — the occupant renders `null` while closed |
| `anchorRef` | Ref onto the host's trigger chip, used to position the menu |
| `selectedId` | Currently active workspace (rendered with a trailing check) |
| `onPick` | Choose a workspace; the host connects or reuses its blank session |
| `onClose` | Dismiss — bound to <kbd>Esc</kbd> and to any press outside |
The menu is `position: fixed` and measured from the anchor rect, so it escapes the hero's `overflow: hidden auto` scroll body; it flips above the chip when there is no room below.
| Key | Behaviour |
| --- | --- |
| <kbd>↑</kbd> / <kbd>↓</kbd> | Move the cursor, wrapping at both ends |
| <kbd>Home</kbd> / <kbd>End</kbd> | Jump to the first / last row |
| <kbd>Enter</kbd> / <kbd>Space</kbd> | Commit the row under the cursor |
| <kbd>Esc</kbd> | Close, and hand focus back to the host's chip |
| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Close and hand focus back, rather than leaving an open menu sitting behind whatever was tabbed into |
| Any printable character | Typeahead — jump to the next row whose name starts with it; press it again to cycle through rows sharing that initial |
Three details the keyboard route depends on, each of which a simpler implementation gets wrong:
- **Focus enters on the cursor's row, not on the menu container.** A cursor that starts at row 0 has to already own focus, or the first <kbd>↓</kbd> steps to row 1 and the top of the list is silently skipped.
- **The cursor opens on the host-reported selection**, so the operator sees where they are before moving — and is clamped on read, so a refresh that removes rows from under it still leaves one row carrying `tabindex=0`. Unclamped, the menu would keep rendering while quietly ceasing to be keyboard-navigable.
- **A pending snapshot reads as loading, not as empty.** Before the workspace list arrives, "no workspaces yet" is a flash of a wrong answer.
### Adding a workspace still works
The host's `renderSlot` call carries **no** creation callback, so a reskin that renders only the list would silently drop the hero's "Add workspace…" action. This entry avoids that the same way the shipped picker does — by supplying the missing half itself:
- it declares a `conversation.hero.workspace.directoryFlow` child hole, which any composed directory-picker package fills exactly as it would for the native picker;
- it injects `createWorkspace` (straight off the `workspaces` service) into itself;
- the add row rides that hole's **occupancy**, so it appears and disappears as directory pickers load and unload.
Adopting a picked directory is one interaction at a time: while the flow is open or a path is being adopted, every row is disabled. A rejected `createWorkspace` surfaces in a retryable dialog rather than vanishing, and an occupant that unloads mid-interaction withdraws the flow so the rows come back.
That dialog is a real modal: it traps <kbd>Tab</kbd> inside itself (as `aria-modal` promises rather than merely declares), closes on <kbd>Esc</kbd>, and hands focus back to the host's chip when dismissed — except on retry, where the picking flow takes focus instead and the two do not fight over the same keystroke.
> The hero exposes no slot for a welcome headline or entry chips — `hero.headline` is hardcoded copy in the host, not a slot. A Wordless-style welcome panel therefore has nowhere to live without fighting the host layout; this plugin reskins what dsh actually offers.
## Install
```bash
dsh plugin add ml020/dsh-workbuddy
```
## Design
Tokens live in `src/client/styles/tokens.module.css`, scoped under `.root` with a `--wb-*` prefix so they never collide with dsh's own `--dsw-*` theme variables. Light and dark follow `prefers-color-scheme`.
| Token | Light | Dark |
| --- | --- | --- |
| Workspace | `#fbfbfa` | `#151610` |
| Sidebar | `#f1f1ee` | `#202219` |
| Accent | `#ccf257` | `#bfe650` |
| Selected row bg | `#eef4dc` | `#303a1c` |
Type is **Manrope** for UI and **DM Mono** for workspace paths.
## Layout
```
src/
index.ts host loader (no-op, browser-only plugin)
invariant.ts package-owned invariant companion
client/
index.ts slot registration + directory-flow occupancy source
BrandMark.tsx conversation.hero.brand.mark
WorkspaceMenu.tsx conversation.hero.workspace (+ the add flow)
locales.ts zh / en dictionaries (namespace `workbuddy`)
icons.tsx inline SVG
styles/ CSS Modules + design tokens
scripts/
check-bundle.mjs asserts lib/ still meets the dsh packaging contract
tests/
browser-plugin.client.spec.tsx
invariant.client.spec.ts
mock-slots.ts slot-registry and framework-prop doubles
setup.ts the layout API jsdom does not implement
```
## Development
```bash
npm install
npm run typecheck # tsc --noEmit (no errors)
npm run test # vitest run
npm run lint # oxlint
npm run build # lib/index.js · lib/invariant.js · lib/client.js
npm run verify # all of the above, plus the packaging contract
```
The dsh UI client packages are `__ModuleLoader__` bundles that only run inside the dsh web host, so the browser specs exercise the plugin against the doubles in `tests/mock-slots.ts` instead of importing them.
### Screenshots
`docs/*.png` are photographed from the real components rather than drawn. `preview/` mounts `BrandMark` and `WorkspaceMenu` inside a host-accurate dsh shell with Vite, and Playwright shoots it at 2× in light, dark and selected states.
```bash
cd preview
npm install
npx playwright install chromium # once per machine
npm run shoot
```
One detail the harness handles: the menu focuses its cursor row when it opens, and with no prior input the browser credits that scripted focus as a *keyboard* focus and paints `:focus-visible` into the shot. The harness blurs before capturing, so the previews show what clicking the chip actually looks like.
`npm run check:bundle` exists because nothing in `tsc`, `vitest` or `oxlint` looks at `lib/`, and the failure mode is silent. The host loads the client half as a CommonJS closure factory with a specific banner and footer, and resolves every cross-plugin import through its own module table: drop the footer, or let the bundler inline React, and the package still builds, still passes every test, and throws at load time in a browser console nobody is watching. The checks are asserted against the artifact after a real build, and CI runs them the same way.
## License
MIT
Install
dsh plugin --profile web add github:ml020/dsh-workbuddy#864f5bec80b9e9873990149f01ea2fdaad004f76
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-workbuddy from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.