dsh-workbuddy
开发工具 活跃维护

dsh-workbuddy

ml020/dsh-workbuddy

轻量替换DSH空白会话的品牌标识与工作区选择器,保留原生编辑器能力,适配Wordless风格设计,无需改动原生功能逻辑即可完成会话入口界面的定制化,完全不影响原有操作习惯。

0
Stars 标星
0
Forks 分支
0
Watchers 关注
0
Open Issues
TypeScript
主要语言
MIT
开源协议
963 KB
仓库大小
24 天前
最后推送
一键安装扩展 / 插件指令
dsh plugin --profile web add github:ml020/dsh-workbuddy
git clone https://github.com/ml020/dsh-workbuddy.git
git clone git@github.com:ml020/dsh-workbuddy.git
README.md main

dsh-workbuddy

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

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

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