Skip to content
dsh.fish
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

  • 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.
Source