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.

light

What it changes

SlotKindWhat you get
conversation.hero.brand.marksingleLime gradient tile with the WorkBuddy W
conversation.hero.workspacesingleWordless-styled menu, anchored to the host's chip
conversation.hero.workspace.directoryFlowsingleChild hole this entry declares, so directory pickers still work
conversation.composer.barUntouched — 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:

PropMeaning
openWhether the picker is expanded — the occupant renders null while closed
anchorRefRef onto the host's trigger chip, used to position the menu
selectedIdCurrently active workspace (rendered with a trailing check)
onPickChoose a workspace; the host connects or reuses its blank session
onCloseDismiss — 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.

KeyBehaviour
/ Move the cursor, wrapping at both ends
Home / EndJump to the first / last row
Enter / SpaceCommit the row under the cursor
EscClose, and hand focus back to the host's chip
Tab / Shift+TabClose and hand focus back, rather than leaving an open menu sitting behind whatever was tabbed into
Any printable characterTypeahead — 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.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 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.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

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.

TokenLightDark
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