dsh-workbuddy
August 31, 2026 · View on GitHub
Wordless-style skin for the blank-session hero of DeepSeek Harness (dsh).
Restyles the two hero slots — the brand tile and the workspace picker menu — with the warm, minimal look of Wordless. The native dsh web composer (the dialog box) and the native picker chip are never touched.
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 Esc 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 |
|---|---|
| ↑ / ↓ | Move the cursor, wrapping at both ends |
| Home / End | Jump to the first / last row |
| Enter / Space | Commit the row under the cursor |
| Esc | Close, and hand focus back to the host's chip |
| Tab / Shift+Tab | 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 ↓ 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.directoryFlowchild hole, which any composed directory-picker package fills exactly as it would for the native picker; - it injects
createWorkspace(straight off theworkspacesservice) 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 Tab inside itself (as aria-modal promises rather than merely declares), closes on Esc, 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.headlineis 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
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
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.
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