dsh-composer-ux 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.18.0] — 2026-10-09(工作区目录选择:桌面弹系统文件夹框,手机/远端仍用官方应用内框)
6
+
7
+ 用户 2026-10-09:先要「换盘不用手输」,选了 A 方案(profile 补丁钉官方 `-native`,实测通过),
8
+ 随后又追加「手机端也得能在界面里浏览目录」—— 也就是**自适应**。
9
+
10
+ **为什么原来两头只能选一头**:官方把目录选择做成两个互斥界面(`-native` OS 选择器 / `-browse`
11
+ 应用内 Miller 分栏),由 `-auto` 在**宿主启动时**按「绑定地址 / SSH / 平台」二选一,**不看客户端是谁**。
12
+ 官方 README 承认 `-browse` 的缺口:
13
+
14
+ > **不枚举盘符根** —— Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口。
15
+
16
+ 而社区 bundle `dsh-mobile` 为手机端把 browse 钉死 ⇒ 桌面也只剩应用内框、换盘必须手输。
17
+ 「按客户端自适应」被官方列为 **deferred**(`directoryPicker` 服务只能有一个实现、目录流程槽是 `single`)。
18
+
19
+ **本版的解法**:绕开"二选一"用的是**槽位自身的 shadow 能力**(官方 `ui-slots` 源码:
20
+ 同 cell 上不同优先级可共存、**最低的活条目渲染**,只有同优先级才会抛
21
+ `single slot "…" already has a registration`):
22
+
23
+ - 桌面应用(preload 注入了 `window.__DSH_DIRECTORY_PICKER__`)⇒ 本插件在**优先级 `-1`** 上接管
24
+ `conversation.hero.workspace.directoryFlow` 与 `sidebar.workspaces.directoryFlow`,注册一个
25
+ **无界面**占用者,直接弹 **Windows 系统文件夹框**(任意盘符点进去、可新建文件夹);
26
+ - 手机 / 远端浏览器没有这个桥 ⇒ **一个槽位都不注册**,官方应用内浏览对话框照旧渲染。
27
+ - 不碰官方任何文件、不抄官方那一千多行界面、**profile 不需要任何补丁**(先前的 A 方案补丁已回滚)。
28
+
29
+ 细节与边界(都有断言 + 变异守着):
30
+
31
+ - **桥的探测逐项校验形状**(不是对象 / 没有 `pick` / `pick` 不是函数都当"没有桥"):宁可退化成官方
32
+ 应用内界面,也不要在用户点击时抛 `pick is not a function`(持有方只会把它显示成"选择失败");
33
+ - **一条 `open` 上升沿只弹一次**:持有方正在采纳(`open` 仍为 true、`busy`)时的重渲染不会再弹一次,
34
+ `open` 落回才重新上膛;
35
+ - **三态各一条路**:取消(`pick` resolve 成 null)只关掉流程;拿到路径即采纳;失败显示 `Error.message`
36
+ (不是 `Error: xxx`);
37
+ - **卸载后迟到的结果丢弃**:原生对话框没有按请求中止的能力,HMR 换掉占用者后旧实例的请求仍会落地,
38
+ 让它去 `onPicked` 会凭空多出一个工作区;
39
+ - 客户端 `inject` 仍然只有 `slots`(**没有**引入 `uiWorkspace`)—— 少一个依赖就少一种"等不到依赖
40
+ 而整块不挂载"的死法。
41
+
42
+ **验证**:
43
+
44
+ - `npm test`:**29 个套件 2510 passed / 0 failed**。新增套件 `test/directory-flow.mjs`(37 条:
45
+ 桥形状 7 / 状态机 8 / 三态 4 / 存活 2 / 结果映射 1 / shadow 注册 15)。
46
+ - `npm run test:mutations`:**174 条全部咬住**(新增 FS–FY 共 7 条,专打本次的判定与状态机:
47
+ 远端也注册、优先级写成 0、去掉上膛守卫、落回不上膛、取消当选中、卸载后仍回报、丢掉 `Error.message`)。
48
+ - 真机:桌面应用里点「选择工作区目录」直接弹系统文件夹框(2026-10-09 用户实测)。
49
+ - **未验证**:真手机上回落到应用内框这一支 —— 只有单测与「没有桥就不注册」的逻辑保证,
50
+ README 的「未验证」表里如实列着;电脑上的普通浏览器(不是桌面应用窗口)也走这一支。
51
+
52
+ ## [0.17.2] - 2026-10-01(修掉设置页里会原样显示的星号)
53
+
54
+ 0.17.1 我把 markdown 的粗体星号直接写进了 JSX,界面会把星号一起显示出来。本次只去掉星号,逻辑与断言不变(28 套件 2473 条 0 失败、变异 167/167)。
55
+
5
56
  ## [0.17.1] — 2026-10-01(说清「两条通道不要同时开同一类事件」)
6
57
 
7
58
  用户的决定(2026-10-01):**个人微信交给 dsh-notify-plugin**(它自己扫码、自己实现 iLink 协议),
package/README.md CHANGED
@@ -475,6 +475,32 @@ Windows 上 DSH 给模型的终端工具是 **PowerShell**(工具名 `pwsh`)
475
475
  - **六条自开路由全部过了官方那道关卡**:`connection.requestRejection(request)`(Host/Origin 围栏 + 浏览器登录令牌)。`webServer` 是可以绑 `0.0.0.0` 的,漏掉它等于把会话用量、设置写入与**账号余额**摊给同网段任何一台机器 —— 2026-09-29 评审时先补了金额那三条,同日对比社区插件时又发现**「优化提示词」与「快捷指令存储」这两条也漏着**(一个花你的模型额度、一个会覆盖你的提示词库),一并补上,且关卡摆在**读请求体之前**(被拒的请求连 body 都不读)。现在 6/6:用量、价目同步、余额、重启、优化、快捷指令存储(终端状态那条在 `terminal/host.ts` 里自带同一道关卡)。变异 `CN`/`CO` 守着这两条。
476
476
  - **与出处有意不同的两处**(都钉在测试里):① 金额**至少保留两位小数**(出处会把 `0.1` 显示成 `¥0.1`、把 `0` 显示成 `¥0.`);② **覆盖价的币种语义**——出处是"有覆盖价时先整体重建人民币档、美元再从人民币折算",写成"在美元列上套覆盖价再折算"会差一个汇率(≈15 倍)而屏幕上只是个数字,所以专门有一条断言守着。
477
477
 
478
+ ## 工作区目录选择:桌面用系统文件夹框,手机/远端用官方应用内框(0.18.0)
479
+
480
+ **为什么会有这一节**:DSH 官方的目录选择是两个**互斥**的界面 —— `-native`(本机 OS 选择器,Windows 上就是系统文件夹框)与 `-browse`(应用内 Miller 分栏对话框),由 `-auto` 在**宿主启动时**二选一,判据只有「绑定地址 / SSH / 平台」,**不看客户端是谁**。官方在 `-browse` 的 README「已知限制」里承认了由此产生的缺口:
481
+
482
+ > **不枚举盘符根** —— Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口。
483
+
484
+ 于是本机换盘必须手输路径;而社区 bundle `dsh-mobile` 为了手机端能选目录又把 browse 钉死,连桌面也一起用上了应用内框(官方把「按客户端自适应」明确列为 deferred)。
485
+
486
+ **本插件怎么做的**(不改官方任何文件、不抄官方那一千多行界面):用槽位自身的 shadow 能力 —— 官方 `ui-slots` 的规则是「entries sharing one cell coexist at distinct priorities … the cell's lowest live entry renders」,只有**同优先级**的第二次注册才报错:
487
+
488
+ | 客户端 | 有没有 preload 桥 | 本插件 | 你看到什么 |
489
+ |---|---|---|---|
490
+ | 桌面应用(`DeepSeek Harness.exe`) | 有(`window.__DSH_DIRECTORY_PICKER__`) | 在优先级 `-1` 上接管这两个目录流程槽位 | 直接弹 **Windows 文件夹框**:任意盘符点进去、可新建文件夹,**不用手输** |
491
+ | 手机 / 远端浏览器 | 没有 | **一个槽位都不注册** | 官方那个应用内浏览对话框(路径输入、层级列表、隐藏文件开关、新建文件夹) |
492
+
493
+ 所以两件事同时成立:桌面不再手输盘符,手机端体验**零退化**;也**不需要任何 profile 补丁**(插件不装 = 恢复官方原样)。
494
+
495
+ 几条边界(都是刻意的):
496
+
497
+ - **判据是 preload 桥,不是"你是不是本机"**:在电脑上用普通浏览器打开 `127.0.0.1:19387`(不是桌面应用窗口)也会走应用内框 —— 客户端拿不到"我离宿主多远"这个事实,官方把「按连接自适应」列为 deferred。
498
+ - **优先级必须是负数**:官方界面注册在默认优先级 `0`;同优先级会撞上「第二次注册」⇒ 安装期直接抛 `single slot "…" already has a registration`,整块客户端插件挂不上。`test/directory-flow.mjs` 钉住这条。
499
+ - **一条上升沿只弹一次**:持有方正在采纳(`open` 仍为 true、`busy`)时的重渲染不会再弹一次;`open` 落回才重新上膛。
500
+ - **卸载后迟到的结果丢弃**:原生对话框没有按请求中止的能力;HMR 换掉占用者后旧实例的请求仍会落地,让它去 `onPicked` 会凭空多出一个工作区。
501
+ - **取消与失败各是一条路**:取消(`pick` resolve 成 null)→ 持有方只关掉流程;失败(抛异常)→ 界面显示 `Error.message`(不是 `Error: xxx`)。
502
+ - **只依赖槽位服务**:本插件客户端半的 `inject` 仍然只有 `slots`,没有引入 `uiWorkspace` —— 少一个依赖就少一种"等不到依赖而整块不挂载"的死法。
503
+
478
504
  ## 维护:重启 DSH
479
505
 
480
506
  装了新插件、改了宿主半代码(比如本插件的「默认终端」、键位 schema)之后,DSH 需要重启才会加载新代码。设置页「输入体验」卡片**抬头右端**(GitHub 链接左边)有一枚「重启 DSH」。
@@ -518,7 +544,8 @@ Windows 上 DSH 给模型的终端工具是 **PowerShell**(工具名 `pwsh`)
518
544
  | 工具循环的消息形状 / 轮次封顶 / 异常必须降级 | 同上第 6 节(假 llm 驱动整条循环,逐条检查 `role:'tool'` 与 `toolCallId`) |
519
545
  | 工具路径:默认关、开了才派、**查完吐散文必须回落**、拿不到工作目录就不派 | `test/quick-commands.mjs` 5g 节(13 条,真路由 + 假 llm) |
520
546
  | 工具读到的内容不进台账 | 同 5g 节:断言台账里只有工具名与次数、没有读到的文件内容 |
521
- | 变异守卫真的会咬人,且**必须是指定的那条测试变红** | `npm run test:mutations`:**167 条全部咬住**(判据是 `red.some(line => line.includes(item.expect))`,不是"有红就行") |
547
+ | 变异守卫真的会咬人,且**必须是指定的那条测试变红** | `npm run test:mutations`:**174 条全部咬住**(判据是 `red.some(line => line.includes(item.expect))`,不是"有红就行") |
548
+ | **桌面应用里弹系统文件夹框**(0.18.0 工作区目录选择) | ✅ **有真机证据**:2026-10-09 用户在桌面应用点「选择工作区目录」,实测「成功替换为系统文件管理器」(先用 profile 补丁验过一次,之后的插件内 shadow 版走的是同一条 preload 桥);`node test/directory-flow.mjs` 37 条 + 变异 FS–FY 罩着判定与状态机 |
522
549
  | 发布门禁三条判据 | `npm run gates` 全过;`check:tag` 实测 `v0.12.0` 的 tag 名与所指提交里的版本一致 |
523
550
  | 三平台 CI | GitHub Actions:ubuntu / windows / macos × Node 20(`npm ci → typecheck → build → test → check:pack → check:docs`) |
524
551
 
@@ -538,6 +565,7 @@ Windows 上 DSH 给模型的终端工具是 **PowerShell**(工具名 `pwsh`)
538
565
  | 会话上下文「只用于消歧义」 | 0.12.0 起就声明过:这是**结构约束**(上下文内容不能当引文),不是对模型行为的保证。 |
539
566
  | `sync-types` 的边界 | 它是**形状核对**,不是类型代码生成:宿主 API 真变了但形状没变的那种情况查不出来。 |
540
567
  | 界面语言 | 只有中文界面(上游做了 zh/en 跟随 DSH)。英文用户能读 `README.en.md`,但界面本身不跟语言。 |
568
+ | **远端(手机/浏览器)回落到官方应用内框**(0.18.0) | 判据是「客户端有没有 preload 桥」,`test/directory-flow.mjs` 第六节钉住「没有桥 ⇒ 一个槽位都不 inject、`installDirectoryFlow` 返回 false」;但**没有在真手机上点过这一次**。另外「电脑上的普通浏览器」也走这一支(客户端拿不到"我离宿主多远",官方把这个能力列为 deferred)。 |
541
569
 
542
570
  ## 结构
543
571
 
@@ -652,9 +680,9 @@ node build.mjs # 产出 lib/index.js + lib/client.js
652
680
  # ⚠️ 宿主半会**内联** schemastery / cosmokit:
653
681
  # 优先用 DSH 检出里的 vendor 副本,检出不在时退到 node_modules
654
682
  # 里同版本的 npm 包(两者逐字节相同)——CI 上走的就是退路
655
- npm test # 28 个套件;当前 2473 passed, 0 failed(2026-09-29 实测;CI 三平台同样全绿)
683
+ npm test # 29 个套件;当前 2510 passed, 0 failed(2026-10-09 实测;CI 三平台同样全绿)
656
684
  # 走 scripts/run-tests.mjs:顺带把"多少套件/多少条"记进 test/.last-run.json
657
- npm run test:mutations # 手动跑:变异测试,证明那套护栏真的在咬人(167 条,须单独跑)
685
+ npm run test:mutations # 手动跑:变异测试,证明那套护栏真的在咬人(174 条,须单独跑)
658
686
  # 同样记录结果,供下面的文档门禁核对
659
687
  npm run gates # 发版门禁三条一起跑:tag 指向 / 包内容 / 文档数字
660
688
  npm run check:tag # ① tag 名里的版本 == 该 tag 所指提交里的 package.json 版本
package/lib/client.js CHANGED
@@ -5902,7 +5902,7 @@ function NotifyCard({ settings, setField }) {
5902
5902
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("strong", { children: "\u53EA\u80FD\u901A\u77E5\uFF0C\u4E0D\u80FD\u56DE\u3002" }),
5903
5903
  "\u9700\u8981\u4F60\u56DE\u5E94 / \u5B8C\u6210 / \u51FA\u9519\u65F6\u63A8\u4E00\u6761\u5230\u4F60\u624B\u673A\uFF1B\u8981\u5904\u7406\u4ECD\u56DE DSH \u754C\u9762\u70B9\u3002",
5904
5904
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("br", {}),
5905
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { style: { opacity: 0.75 }, children: '\u5982\u679C\u4F60\u540C\u65F6\u88C5\u4E86 dsh-notify-plugin\uFF08\u4E2A\u4EBA\u5FAE\u4FE1\u76F4\u8FDE\u90A3\u6761\uFF09\uFF0C**\u540C\u4E00\u7C7B\u4E8B\u4EF6\u53EA\u5F00\u4E00\u8FB9** \u2014\u2014 \u4E24\u8FB9\u90FD\u5F00\u5C31\u4F1A\u6536\u5230\u4E24\u6761\u3002\u8FD9\u4E00\u6761\u662F\u7ED9"\u4E0D\u60F3\u518D\u88C5\u4E00\u4E2A\u63D2\u4EF6"\u7684\u4EBA\u7559\u7684\u9000\u8DEF\u3002' })
5905
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { style: { opacity: 0.75 }, children: '\u5982\u679C\u4F60\u540C\u65F6\u88C5\u4E86 dsh-notify-plugin\uFF08\u4E2A\u4EBA\u5FAE\u4FE1\u76F4\u8FDE\u90A3\u6761\uFF09\uFF0C\u540C\u4E00\u7C7B\u4E8B\u4EF6\u53EA\u5F00\u4E00\u8FB9 \u2014\u2014 \u4E24\u8FB9\u90FD\u5F00\u5C31\u4F1A\u6536\u5230\u4E24\u6761\u3002\u8FD9\u4E00\u6761\u662F\u7ED9"\u4E0D\u60F3\u518D\u88C5\u4E00\u4E2A\u63D2\u4EF6"\u7684\u4EBA\u7559\u7684\u9000\u8DEF\u3002' })
5906
5906
  ] }),
5907
5907
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
5908
5908
  ToggleRow,
@@ -8326,6 +8326,87 @@ function clearDockStateOnHost() {
8326
8326
  return saveDockState(null);
8327
8327
  }
8328
8328
 
8329
+ // src/client/directory-flow.ts
8330
+ var DIRECTORY_FLOW_SLOTS = [
8331
+ "conversation.hero.workspace.directoryFlow",
8332
+ "sidebar.workspaces.directoryFlow"
8333
+ ];
8334
+ var NATIVE_SHADOW_PRIORITY = -1;
8335
+ function nativePickBridge(scope) {
8336
+ const candidate = scope?.__DSH_DIRECTORY_PICKER__;
8337
+ if (candidate === null || typeof candidate !== "object") return void 0;
8338
+ if (typeof candidate.pick !== "function") return void 0;
8339
+ return candidate;
8340
+ }
8341
+ function applyOutcome(owner, outcome) {
8342
+ if (outcome.kind === "picked") owner.onPicked(outcome.path);
8343
+ else if (outcome.kind === "cancel") owner.onCancel();
8344
+ else owner.onError(outcome.message);
8345
+ }
8346
+ function createPickFlow(options) {
8347
+ let armed = false;
8348
+ const settle = (outcome) => {
8349
+ if (options.isAlive !== void 0 && !options.isAlive()) return;
8350
+ options.report(outcome);
8351
+ };
8352
+ return {
8353
+ setOpen(open) {
8354
+ if (!open) {
8355
+ armed = false;
8356
+ return;
8357
+ }
8358
+ if (armed) return;
8359
+ armed = true;
8360
+ options.pick().then(
8361
+ (path) => {
8362
+ settle(path === null ? { kind: "cancel" } : { kind: "picked", path });
8363
+ },
8364
+ (reason) => {
8365
+ settle({ kind: "error", message: reason instanceof Error ? reason.message : String(reason) });
8366
+ }
8367
+ );
8368
+ },
8369
+ armed: () => armed
8370
+ };
8371
+ }
8372
+ function installDirectoryFlow(slots, scope, component) {
8373
+ const bridge = nativePickBridge(scope);
8374
+ if (bridge === void 0) return false;
8375
+ const pick = () => bridge.pick();
8376
+ const injected = () => ({ pick });
8377
+ slots.inject(DIRECTORY_FLOW_SLOTS[0], () => slots.inject(DIRECTORY_FLOW_SLOTS[1], function* () {
8378
+ yield slots.register({ name: DIRECTORY_FLOW_SLOTS[0], inject: injected, priority: NATIVE_SHADOW_PRIORITY }, component);
8379
+ yield slots.register({ name: DIRECTORY_FLOW_SLOTS[1], inject: injected, priority: NATIVE_SHADOW_PRIORITY }, component);
8380
+ }));
8381
+ return true;
8382
+ }
8383
+
8384
+ // src/client/DirectoryFlowEntry.tsx
8385
+ var import_react17 = require("react");
8386
+ function DirectoryFlowEntry(props) {
8387
+ const { open, pick } = props;
8388
+ const alive = (0, import_react17.useRef)(true);
8389
+ const latest = (0, import_react17.useRef)(props);
8390
+ latest.current = props;
8391
+ const flow = (0, import_react17.useMemo)(() => createPickFlow({
8392
+ pick,
8393
+ report: (outcome) => {
8394
+ applyOutcome(latest.current, outcome);
8395
+ },
8396
+ isAlive: () => alive.current
8397
+ }), [pick]);
8398
+ (0, import_react17.useEffect)(() => {
8399
+ alive.current = true;
8400
+ return () => {
8401
+ alive.current = false;
8402
+ };
8403
+ }, []);
8404
+ (0, import_react17.useEffect)(() => {
8405
+ flow.setOpen(open);
8406
+ }, [flow, open]);
8407
+ return null;
8408
+ }
8409
+
8329
8410
  // src/client.tsx
8330
8411
  var name = "composer-ux";
8331
8412
  var inject = ["slots"];
@@ -8339,6 +8420,7 @@ var NULL_SCOPE = {
8339
8420
  };
8340
8421
  function apply(ctx) {
8341
8422
  const slots = ctx.slots;
8423
+ installDirectoryFlow(slots, globalThis, DirectoryFlowEntry);
8342
8424
  const live = (0, import_dsh_client_store.createSnapshotStore)({ ...DEFAULT_SETTINGS });
8343
8425
  const menu2 = (0, import_dsh_client_store.createSnapshotStore)(null);
8344
8426
  const panel = (0, import_dsh_client_store.createSnapshotStore)(null);