@mzzsfy/dsh-context-manager 0.3.0 → 0.4.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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  输入框按 `Alt+↑`,或点击输入框下方工具排的历史按钮(指令/附件右侧,与 Alt+↑ 等效,再点关闭)唤起历史输入浮层:
10
10
 
11
- - 范围导航:`←/→` 按 常用(prompts)→ 当前会话(session)→ 本工作区(workspace)→ 全部工作区(global) 依次切换(边界停住),列表时间倒序
11
+ - 范围导航:顶栏范围标签点击展开 pill 组选择;`←/→` 按 常用(prompts)→ 当前会话(session)→ 本工作区(workspace)→ 全部工作区(global) 依次切换(边界停住),列表时间倒序
12
12
  - 收录范围:普通输入与斜杠命令(如 `/goal …`,按会话日志的 command/run 重组为完整命令行),工具结果回填与插件注入不收录
13
13
  - 搜索:输入即时过滤(IME 组合输入守卫,命中计数),`↑/↓` 选中、`Enter` 回填
14
14
  - 回填经宿主公共契约 `inputActions.setDraft`;`Esc` 两级退出(搜索词非空先清词,再按关闭浮层)
@@ -66,4 +66,4 @@
66
66
 
67
67
  ## dsh 版本兼容
68
68
 
69
- 三版本全部通过:样式注入 data-plugin 契约、历史输入浮层(Alt+↑ 或输入框按钮)、对话 fork、设置「插件」页对话增强卡开关组、host 半区 API(八条 `/api/context/*`)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
69
+ 三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)兼容通过:样式注入 data-plugin 契约、历史输入浮层(Alt+↑ 或输入框按钮)、启停路由与方法白名单、激活 live。0.1.6 设置分区卡片位于「插件」页,非独立导航项;0.1.7-rc.1 内置插件页改版为分组列表(无卡片开关锚),样式与 API 面实测正常。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-context-manager",
3
3
  "description": "对话增强:历史输入浮层(范围导航/搜索/subagent排除)、常用提示词收藏、插话撤回、对话 fork(进行中轮分叉/自动重发)",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -18,6 +18,7 @@ window.__ModuleLoader__.load({
18
18
  factory(require) {
19
19
  const React = require('react')
20
20
  const { useState, useEffect, useRef } = React
21
+ const PKG_ID = '@mzzsfy/dsh-context-manager'
21
22
 
22
23
  // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;键 = 市场短名(发现页
23
24
  // 收录显示形态);该插件未就绪时入队,由其启动时排空
@@ -80,11 +81,19 @@ const CSS = [
80
81
  ' color-scheme:light dark; color:light-dark(#0f1115, #e8eaed);',
81
82
  ' background:light-dark(#fff, #1e1f22); border-radius:14px;',
82
83
  ' box-shadow:0 0 0 0.5px light-dark(rgba(15,17,21,.18), rgba(255,255,255,.14)), 0 4px 16px rgba(0,0,0,.08), 0 16px 48px rgba(0,0,0,.16); }',
83
- '.cx-hist__hint { display:flex; align-items:center; gap:10px; padding:9px 14px; flex:none;',
84
+ '.cx-hist__hint { display:flex; flex-wrap:wrap; align-items:center; gap:10px; padding:9px 14px; flex:none;',
84
85
  ' border-bottom:1px solid light-dark(rgba(15,17,21,.08), rgba(255,255,255,.1));',
85
86
  ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:light-dark(rgba(15,17,21,.55), rgba(232,234,237,.55)); }',
86
- '.cx-hist__scope { padding:2px 10px; border-radius:999px; flex:none;',
87
- ' background:#1677ff; color:#fff; font-weight:600; }',
87
+ // 范围 pill 组:点击直达切换,选中实底蓝,未选淡底
88
+ '.cx-hist__scopebar { display:flex; align-items:center; gap:4px; flex:none; }',
89
+ '.cx-hist__pill { appearance:none; border:0; cursor:pointer; flex:none; padding:2px 9px;',
90
+ ' border-radius:999px; font:var(--dsw-font-xxs-12, 12px/18px sans-serif);',
91
+ ' color:light-dark(rgba(15,17,21,.6), rgba(232,234,237,.6));',
92
+ ' background:light-dark(rgba(15,17,21,.06), rgba(255,255,255,.08)); }',
93
+ '.cx-hist__pill:hover { color:light-dark(rgba(15,17,21,.85), rgba(232,234,237,.85));',
94
+ ' background:light-dark(rgba(15,17,21,.1), rgba(255,255,255,.14)); }',
95
+ '.cx-hist__pill--on, .cx-hist__pill--on:hover { background:#1677ff; color:#fff; font-weight:600; }',
96
+ '.cx-hist__pill:focus-visible { outline:2px solid #1677ff; outline-offset:1px; }',
88
97
  '.cx-hist__search { flex:1; min-width:0; padding:3px 10px; border-radius:8px; color-scheme:light dark;',
89
98
  ' border:1px solid light-dark(rgba(15,17,21,.14), rgba(255,255,255,.18));',
90
99
  ' background:transparent; color:inherit; font:var(--dsw-font-xxs-12, 12px/18px sans-serif); }',
@@ -301,6 +310,8 @@ function HistoryDock({ session, inputActions }) {
301
310
  const [items, setItems] = useState(null)
302
311
  const [cursor, setCursor] = useState(-1)
303
312
  const [scopeIndex, setScopeIndex] = useState(HISTORY_SCOPE_DEFAULT)
313
+ // 范围标签展开态:默认单胶囊只显当前范围,点击展开 pill 组,选择后收起
314
+ const [scopeOpen, setScopeOpen] = useState(false)
304
315
  const [aligning, setAligning] = useState(false)
305
316
  const [loadError, setLoadError] = useState(false)
306
317
  // 收藏集合:浮层打开时并行拉一次,行悬停星标据此显示实/空心;
@@ -456,6 +467,7 @@ function HistoryDock({ session, inputActions }) {
456
467
  rawItemsRef.current = null
457
468
  queryRef.current = ''
458
469
  setQuery('')
470
+ setScopeOpen(false)
459
471
  syncView({ open: true, items: null, cursor: -1, scopeIndex: HISTORY_SCOPE_DEFAULT, aligning: false })
460
472
  setOpen(true)
461
473
  setScopeIndex(HISTORY_SCOPE_DEFAULT)
@@ -468,10 +480,10 @@ function HistoryDock({ session, inputActions }) {
468
480
  requestScope(HISTORY_SCOPE_DEFAULT)
469
481
  }
470
482
 
471
- // 切换范围:→ 向大(工作区/全局),← 返回收藏;边界停住;搜索词清空后缓存直接返回
472
- function switchScope(delta) {
473
- const next = viewRef.current.scopeIndex + delta
474
- if (next < 0 || next >= HISTORY_SCOPE_LABELS.length) return
483
+ // 范围切换共用落点:清过滤与选择态后拉取目标范围;同范围不动,切换即收起展开态
484
+ function selectScope(next) {
485
+ if (next === viewRef.current.scopeIndex) return
486
+ setScopeOpen(false)
475
487
  rawItemsRef.current = null
476
488
  queryRef.current = ''
477
489
  setQuery('')
@@ -485,6 +497,13 @@ function HistoryDock({ session, inputActions }) {
485
497
  requestScope(next)
486
498
  }
487
499
 
500
+ // ←/→ 相对切换:→ 向大(工作区/全局),← 返回收藏;边界停住
501
+ function switchScope(delta) {
502
+ const next = viewRef.current.scopeIndex + delta
503
+ if (next < 0 || next >= HISTORY_SCOPES.length) return
504
+ selectScope(next)
505
+ }
506
+
488
507
  // Alt+↑ 唤起浮层;浮层开 = 菜单模态,捕获阶段拦截导航键,先于 Lexical 光标移动。
489
508
  // 设置开关停用时监听器直接放行(读 ref,开关值挂载拉取后即时反映)
490
509
  useEffect(() => {
@@ -675,7 +694,20 @@ function HistoryDock({ session, inputActions }) {
675
694
  return h('div', { className: 'cx-hist', ref: rootRef },
676
695
  open && h('div', { className: 'cx-hist__pop' },
677
696
  h('div', { className: 'cx-hist__hint' },
678
- h('span', { className: 'cx-hist__scope' }, HISTORY_SCOPE_LABELS[scopeIndex]),
697
+ h('span', { className: 'cx-hist__scopebar' },
698
+ scopeOpen
699
+ ? HISTORY_SCOPE_LABELS.map((label, idx) => h('button', {
700
+ key: label,
701
+ className: 'cx-hist__pill' + (idx === scopeIndex ? ' cx-hist__pill--on' : ''),
702
+ title: idx === scopeIndex ? '收起' : '切换到' + label,
703
+ onClick: () => (idx === scopeIndex ? setScopeOpen(false) : selectScope(idx)),
704
+ }, label))
705
+ : h('button', {
706
+ className: 'cx-hist__pill cx-hist__pill--on',
707
+ title: '点击选择范围',
708
+ onClick: () => setScopeOpen(true),
709
+ }, HISTORY_SCOPE_LABELS[scopeIndex] + ' ▾'),
710
+ ),
679
711
  inPrompts
680
712
  ? h('button', {
681
713
  className: 'cx-hist__editbtn',
@@ -1184,7 +1216,7 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds, refreshRef })
1184
1216
 
1185
1217
  // 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
1186
1218
  // 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
1187
- const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,浏览并回填历史输入;浮层内 ←/→ 切换范围(常用 / 当前会话 / 本工作区 / 全部工作区),顶部搜索框过滤条目,行悬停星标可收藏常用提示词。停用后快捷键与浮层整体关闭,刷新页面生效。'
1219
+ const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,浏览并回填历史输入;浮层内 ←/→ 切换范围,点击顶栏范围标签展开选择(常用 / 当前会话 / 本工作区 / 全部工作区),顶部搜索框过滤条目,行悬停星标可收藏常用提示词。停用后快捷键与浮层整体关闭,刷新页面生效。'
1188
1220
  const HISTORY_BUTTON_SWITCH_TITLE = '输入框下方工具排显示历史输入按钮,点击打开浮层,再点关闭,与 Alt+↑ 等效;停用仅隐藏按钮,快捷键与浮层不受影响;刷新页面生效。'
1189
1221
  const STEER_SWITCH_TITLE = '插话发送后、尚未被智能体应用期间,在该插话气泡的操作图标排显示撤回按钮,点击撤回并把原文填回输入框(覆盖输入框现有草稿);含附件的插话不可撤回;消息被应用后按钮随气泡消失,恰在应用瞬间点击会提示已应用且不动草稿。停用即不再注入,刷新页面生效。'
1190
1222
  const FORK_SWITCH_TITLE = '消息气泡操作排显示分叉按钮,点击分叉出新会话到该轮之前(该轮不带入子会话),该轮的用户输入自动回填子会话输入框供编辑重发,子会话自动打开且标题尾号递增;进行中的轮(回复尚未完成)同样可分叉,分叉后自动停止本会话该轮未完成的回复;首轮(无更早上下文可继承,新建会话即为同义操作)与无文本输入的轮(纯图等,无从重发)不注入;停用即不再注入,刷新页面生效。'
@@ -1364,15 +1396,24 @@ function ContextPanel() {
1364
1396
  }, 'context-manager styles')
1365
1397
 
1366
1398
  // 设置卡片:注册进官方「插件」设置页的可配置插件区(keyed slot,
1367
- // key = 本插件 settings namespace),不占独立设置导航项
1399
+ // key = 本插件 settings namespace),不占独立设置导航项。
1400
+ // 0.1.7-rc.1+ 该槽被宿主移除,卡片换形注册进插件管理页包详情配置槽
1401
+ // (plugins.bundle.config,key = 包名,管理页 ledger.bundles 判定在场);
1402
+ // 两代槽位并存注册:inject 是声明生命周期效应,错误世代宿主永不声明
1403
+ // 该槽,回调挂起不执行,零成本
1368
1404
  ctx.slots.inject('settings.plugin.item', () =>
1369
1405
  ctx.slots.register(
1370
1406
  { name: 'settings.plugin.item', id: 'context', key: 'context' },
1371
1407
  () => React.createElement(ContextPanel),
1372
1408
  ))
1409
+ ctx.slots.inject('plugins.bundle.config', () =>
1410
+ ctx.slots.register(
1411
+ { name: 'plugins.bundle.config', key: PKG_ID },
1412
+ () => React.createElement(ContextPanel),
1413
+ ))
1373
1414
 
1374
1415
  // 历史输入回溯入口:官方 conversation.input.dock 插槽(dsh-client-ui-goal 同构)。
1375
- // 宿主缺该插槽时注册抛错即禁用本功能,不阻塞其余能力
1416
+ // 宿主缺该插槽时 inject 挂起不注册(声明生命周期效应),本功能静默缺席
1376
1417
  try {
1377
1418
  ctx.slots.inject('conversation.input.dock', () =>
1378
1419
  ctx.slots.register(
package/src/index.js CHANGED
@@ -61,6 +61,21 @@ const SETTINGS_SCHEMA = schemastery.object({
61
61
  .description('会话标题栏的复制 sessionId 按钮,平时隐藏,鼠标悬停标题栏时显示,点击复制当前会话 id;关闭后按钮整体不渲染;变更刷新页面生效'),
62
62
  })
63
63
 
64
+ // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
65
+ // settings.register 注册命名空间。根级 volatile 包装:0.1.7 下整节为 live 表单,
66
+ // 节写经 loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
67
+ export const Config = volatileWrap(SETTINGS_SCHEMA)
68
+
69
+ function volatileWrap(schema) {
70
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
71
+ }
72
+
73
+ // volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
74
+ // legacy 宿主 config 为普通对象原样透传
75
+ function unwrapVolatile(value) {
76
+ return typeof value?.get === 'function' ? value.get() : value
77
+ }
78
+
64
79
  function sendJson(res, status, payload) {
65
80
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
66
81
  res.end(JSON.stringify(payload))
@@ -77,9 +92,11 @@ function respondError(ctx, res, error) {
77
92
  sendJson(res, 400, { error: message })
78
93
  }
79
94
 
80
- function readSettings(ctx) {
95
+ function readSettings(ctx, config) {
81
96
  const settings = ctx.get('settings')
82
- const value = settings ? settings.get(NAMESPACE) : undefined
97
+ // 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
98
+ // config 整节解包。方法面守卫兼防:服务在但缺 get 时回落默认值,防路由 handler 抛错
99
+ const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
83
100
  return {
84
101
  historyEnabled: !value || value.historyEnabled !== false,
85
102
  historyButtonEnabled: !value || value.historyButtonEnabled !== false,
@@ -90,6 +107,22 @@ function readSettings(ctx) {
90
107
  }
91
108
  }
92
109
 
110
+ // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
111
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
112
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦),
113
+ // 仅 volatile 字段变化时 loader 原地热更不重启。await 保证持久化完成后再读回
114
+ async function persistToggle(ctx, patch) {
115
+ const settings = ctx.get('settings')
116
+ if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
117
+ await settings.update(NAMESPACE, patch)
118
+ return
119
+ }
120
+ const editor = ctx.get('configEditor')
121
+ const entry = ctx.fiber?.entry
122
+ if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
123
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
124
+ }
125
+
93
126
  function readBody(req) {
94
127
  const BODY_MAX_BYTES = 64 * 1024
95
128
  return new Promise((resolve, reject) => {
@@ -453,7 +486,7 @@ export function apply(ctx, config) {
453
486
  handler: async (req, res) => {
454
487
  try {
455
488
  if (req.method === 'GET') {
456
- sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
489
+ sendJson(res, 200, { enabled: readSettings(ctx, config).historyEnabled })
457
490
  return
458
491
  }
459
492
  if (!rejectMethod(req, res, 'POST')) return
@@ -463,11 +496,8 @@ export function apply(ctx, config) {
463
496
  } catch {
464
497
  throw new Error(MESSAGES.badJsonBody)
465
498
  }
466
- const settings = ctx.get('settings')
467
- if (!settings) throw new Error('宿主设置服务不可用')
468
- // update 异步落盘后才提交新值:await 保证持久化完成后再读回
469
- await settings.update(NAMESPACE, { historyEnabled: Boolean(body && body.enabled) })
470
- sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
499
+ await persistToggle(ctx, { historyEnabled: Boolean(body && body.enabled) })
500
+ sendJson(res, 200, { enabled: readSettings(ctx, config).historyEnabled })
471
501
  } catch (error) {
472
502
  respondError(ctx, res, error)
473
503
  }
@@ -479,7 +509,7 @@ export function apply(ctx, config) {
479
509
  handler: async (req, res) => {
480
510
  try {
481
511
  if (req.method === 'GET') {
482
- sendJson(res, 200, { enabled: readSettings(ctx).historyButtonEnabled })
512
+ sendJson(res, 200, { enabled: readSettings(ctx, config).historyButtonEnabled })
483
513
  return
484
514
  }
485
515
  if (!rejectMethod(req, res, 'POST')) return
@@ -489,11 +519,8 @@ export function apply(ctx, config) {
489
519
  } catch {
490
520
  throw new Error(MESSAGES.badJsonBody)
491
521
  }
492
- const settings = ctx.get('settings')
493
- if (!settings) throw new Error('宿主设置服务不可用')
494
- // update 异步落盘后才提交新值:await 保证持久化完成后再读回
495
- await settings.update(NAMESPACE, { historyButtonEnabled: Boolean(body && body.enabled) })
496
- sendJson(res, 200, { enabled: readSettings(ctx).historyButtonEnabled })
522
+ await persistToggle(ctx, { historyButtonEnabled: Boolean(body && body.enabled) })
523
+ sendJson(res, 200, { enabled: readSettings(ctx, config).historyButtonEnabled })
497
524
  } catch (error) {
498
525
  respondError(ctx, res, error)
499
526
  }
@@ -505,7 +532,7 @@ export function apply(ctx, config) {
505
532
  handler: async (req, res) => {
506
533
  try {
507
534
  if (req.method === 'GET') {
508
- sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
535
+ sendJson(res, 200, { enabled: readSettings(ctx, config).steerRecallEnabled })
509
536
  return
510
537
  }
511
538
  if (!rejectMethod(req, res, 'POST')) return
@@ -515,11 +542,8 @@ export function apply(ctx, config) {
515
542
  } catch {
516
543
  throw new Error(MESSAGES.badJsonBody)
517
544
  }
518
- const settings = ctx.get('settings')
519
- if (!settings) throw new Error('宿主设置服务不可用')
520
- // update 异步落盘后才提交新值:await 保证持久化完成后再读回
521
- await settings.update(NAMESPACE, { steerRecallEnabled: Boolean(body && body.enabled) })
522
- sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
545
+ await persistToggle(ctx, { steerRecallEnabled: Boolean(body && body.enabled) })
546
+ sendJson(res, 200, { enabled: readSettings(ctx, config).steerRecallEnabled })
523
547
  } catch (error) {
524
548
  respondError(ctx, res, error)
525
549
  }
@@ -531,7 +555,7 @@ export function apply(ctx, config) {
531
555
  handler: async (req, res) => {
532
556
  try {
533
557
  if (req.method === 'GET') {
534
- sendJson(res, 200, { enabled: readSettings(ctx).forkEnabled })
558
+ sendJson(res, 200, { enabled: readSettings(ctx, config).forkEnabled })
535
559
  return
536
560
  }
537
561
  if (!rejectMethod(req, res, 'POST')) return
@@ -541,11 +565,8 @@ export function apply(ctx, config) {
541
565
  } catch {
542
566
  throw new Error(MESSAGES.badJsonBody)
543
567
  }
544
- const settings = ctx.get('settings')
545
- if (!settings) throw new Error('宿主设置服务不可用')
546
- // update 异步落盘后才提交新值:await 保证持久化完成后再读回
547
- await settings.update(NAMESPACE, { forkEnabled: Boolean(body && body.enabled) })
548
- sendJson(res, 200, { enabled: readSettings(ctx).forkEnabled })
568
+ await persistToggle(ctx, { forkEnabled: Boolean(body && body.enabled) })
569
+ sendJson(res, 200, { enabled: readSettings(ctx, config).forkEnabled })
549
570
  } catch (error) {
550
571
  respondError(ctx, res, error)
551
572
  }
@@ -557,7 +578,7 @@ export function apply(ctx, config) {
557
578
  handler: async (req, res) => {
558
579
  try {
559
580
  if (req.method === 'GET') {
560
- sendJson(res, 200, { enabled: readSettings(ctx).forkAutoResendEnabled })
581
+ sendJson(res, 200, { enabled: readSettings(ctx, config).forkAutoResendEnabled })
561
582
  return
562
583
  }
563
584
  if (!rejectMethod(req, res, 'POST')) return
@@ -567,10 +588,8 @@ export function apply(ctx, config) {
567
588
  } catch {
568
589
  throw new Error(MESSAGES.badJsonBody)
569
590
  }
570
- const settings = ctx.get('settings')
571
- if (!settings) throw new Error('宿主设置服务不可用')
572
- await settings.update(NAMESPACE, { forkAutoResendEnabled: Boolean(body && body.enabled) })
573
- sendJson(res, 200, { enabled: readSettings(ctx).forkAutoResendEnabled })
591
+ await persistToggle(ctx, { forkAutoResendEnabled: Boolean(body && body.enabled) })
592
+ sendJson(res, 200, { enabled: readSettings(ctx, config).forkAutoResendEnabled })
574
593
  } catch (error) {
575
594
  respondError(ctx, res, error)
576
595
  }
@@ -582,7 +601,7 @@ export function apply(ctx, config) {
582
601
  handler: async (req, res) => {
583
602
  try {
584
603
  if (req.method === 'GET') {
585
- sendJson(res, 200, { enabled: readSettings(ctx).copySidEnabled })
604
+ sendJson(res, 200, { enabled: readSettings(ctx, config).copySidEnabled })
586
605
  return
587
606
  }
588
607
  if (!rejectMethod(req, res, 'POST')) return
@@ -592,10 +611,8 @@ export function apply(ctx, config) {
592
611
  } catch {
593
612
  throw new Error(MESSAGES.badJsonBody)
594
613
  }
595
- const settings = ctx.get('settings')
596
- if (!settings) throw new Error('宿主设置服务不可用')
597
- await settings.update(NAMESPACE, { copySidEnabled: Boolean(body && body.enabled) })
598
- sendJson(res, 200, { enabled: readSettings(ctx).copySidEnabled })
614
+ await persistToggle(ctx, { copySidEnabled: Boolean(body && body.enabled) })
615
+ sendJson(res, 200, { enabled: readSettings(ctx, config).copySidEnabled })
599
616
  } catch (error) {
600
617
  respondError(ctx, res, error)
601
618
  }
@@ -609,6 +626,14 @@ export function apply(ctx, config) {
609
626
  }
610
627
 
611
628
  ctx.inject(['settings'], (settingsCtx) => {
629
+ // 方法面守卫:settings 服务缺 register 面(0.1.7 已移除)即走新形态分支;
630
+ // 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
631
+ if (typeof settingsCtx.settings.register !== 'function') {
632
+ if (typeof settingsCtx.settings?.configure === 'function') {
633
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
634
+ }
635
+ return
636
+ }
612
637
  settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: config })
613
638
  })
614
639
  }
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
7
+
8
+ // 范围标签契约:默认单胶囊只显当前范围,点击展开 pill 组,选择后收起;←/→ 键盘切换走同一落点
9
+ test('源码契约:范围标签默认单胶囊,点击展开 pill 组选择', () => {
10
+ assert.ok(CLIENT_SRC.includes('const [scopeOpen, setScopeOpen] = useState(false)'), '展开态应默认收起')
11
+ assert.ok(CLIENT_SRC.includes('HISTORY_SCOPE_LABELS[scopeIndex]'), '默认应单胶囊显示当前范围')
12
+ assert.ok(CLIENT_SRC.includes('HISTORY_SCOPE_LABELS.map('), '展开后应循环渲染 pill 组')
13
+ assert.ok(CLIENT_SRC.includes('onClick: () => setScopeOpen(true)'), '单胶囊点击应展开')
14
+ assert.ok(CLIENT_SRC.includes('onClick: () => (idx === scopeIndex ? setScopeOpen(false) : selectScope(idx))'), 'pill 点击应直达对应范围,当前项点击收起')
15
+ })
16
+
17
+ test('源码契约:selectScope 为键盘与点击共用的唯一切换落点,切换即收起', () => {
18
+ assert.ok(CLIENT_SRC.includes('function selectScope('), '应有 selectScope 直达切换函数')
19
+ // 键盘相对切换委托 selectScope,消除重复切换链
20
+ assert.ok(/function switchScope\(\s*delta\s*\)\s*\{[\s\S]{0,200}selectScope\(next\)/.test(CLIENT_SRC),
21
+ 'switchScope 应委托 selectScope')
22
+ // 同范围重复点击不重拉
23
+ const body = CLIENT_SRC.match(/function selectScope\([^)]*\)\s*\{[\s\S]*?\n \}/)
24
+ assert.ok(body && body[0].includes('viewRef.current.scopeIndex'), 'selectScope 应以当前范围守卫幂等')
25
+ assert.ok(body && body[0].includes('setScopeOpen(false)'), '切换后应收起展开态')
26
+ })
27
+
28
+ test('源码契约:重开浮层展开态复位,设置悬停说明同步展开语义', () => {
29
+ // openPopup 内 setScopeOpen(false):浮层重开不残留展开态
30
+ assert.ok(/function openPopup\(\)\s*\{[\s\S]{0,300}setScopeOpen\(false\)/.test(CLIENT_SRC),
31
+ '浮层重开应复位展开态')
32
+ assert.ok(CLIENT_SRC.includes('点击顶栏范围标签展开选择'), '历史说明缺范围标签展开选择语义')
33
+ assert.ok(CLIENT_SRC.includes('←/→ 切换范围'), '历史说明应保留键盘切换语义')
34
+ })
@@ -131,4 +131,18 @@ test('运行时:缺 remote.session 时仍完成全部插槽注册', () => {
131
131
  plugin.apply(ctx)
132
132
  const names = slots.map(({ definition }) => definition.name)
133
133
  assert.ok(names.includes('conversation.input.dock'), '输入框 dock 是 fork/历史/撤回三入口的载体')
134
+ })
135
+
136
+ // 双代插件卡:settings.plugin.item(0.1.2-0.1.5)与 plugins.bundle.config(0.1.7-rc.1+)
137
+ // 并存注册,各自宿主只消费其一;两代定义都必须在 apply 后注册在案
138
+ test('运行时:插件卡双代槽位并存注册且组件可渲染', () => {
139
+ const { plugin, slots, ctx } = loadPlugin()
140
+ plugin.apply(ctx)
141
+ const legacy = slots.find(({ definition }) => definition.name === 'settings.plugin.item')
142
+ const modern = slots.find(({ definition }) => definition.name === 'plugins.bundle.config')
143
+ assert.ok(legacy, '缺 settings.plugin.item 注册(0.1.2-0.1.5 设置卡)')
144
+ assert.equal(legacy.definition.key, 'context', '旧代 key 须配对 settings namespace')
145
+ assert.ok(modern, '缺 plugins.bundle.config 注册(0.1.7-rc.1+ 插件管理页卡)')
146
+ assert.equal(modern.definition.key, '@mzzsfy/dsh-context-manager', '新代 key 须为完整包名')
147
+ assert.equal(typeof modern.component, 'function')
134
148
  })
@@ -54,11 +54,16 @@ function makeCtx({
54
54
  settingsValue,
55
55
  settingsGetError,
56
56
  readSessions,
57
+ configRef,
58
+ configEditor,
59
+ settingsService: settingsServiceOverride,
57
60
  }) {
58
61
  const routes = []
59
62
  const pendingInjects = []
60
63
  const settingsState = { value: settingsValue }
61
- const settingsService = {
64
+ // settingsServiceOverride(0.1.7 形态桩,如仅 update 的 forms 面)优先;
65
+ // 缺省时构造 legacy 方法面桩(get/register/update 命名空间语义)
66
+ const settingsService = settingsServiceOverride ?? {
62
67
  // settingsGetError 注入读取期故障:验证 respondError 的系统级错误收敛分支
63
68
  get: () => {
64
69
  if (settingsGetError) throw settingsGetError
@@ -95,7 +100,12 @@ function makeCtx({
95
100
  if (typeof disposer === 'function') effectDisposers.push(disposer)
96
101
  },
97
102
  inject: (_deps, fn) => { pendingInjects.push(fn) },
98
- get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
103
+ // fiber 桩:0.1.7 configure 装配以本条目 fiber 为 owner
104
+ fiber: { entry: { options: { id: 'context-manager', name: '@mzzsfy/dsh-context-manager' } } },
105
+ get: (name) => {
106
+ if (name === 'configEditor') return configEditor
107
+ return { agents, sessionPersistence, settings: settingsService }[name]
108
+ },
99
109
  logger: { warns: [], warn(message) { this.warns.push(message) }, infos: [], info(message) { this.infos.push(message) } },
100
110
  }
101
111
  const ctx = new Proxy(base, {
@@ -108,14 +118,23 @@ function makeCtx({
108
118
  return services[prop]
109
119
  },
110
120
  })
111
- apply(ctx, undefined)
121
+ apply(ctx, configRef)
112
122
  return {
113
123
  handlers: new Map(routes.map((route) => [route.path, route.handler])),
114
124
  logger: base.logger,
115
125
  readCounts,
116
126
  // 模拟宿主 settings 服务激活:触发 inject 回调(注册)
117
127
  activateSettings: () => {
118
- while (pendingInjects.length > 0) pendingInjects.shift()({ settings: settingsService })
128
+ while (pendingInjects.length > 0) {
129
+ pendingInjects.shift()({
130
+ settings: settingsService,
131
+ // 注入上下文的 effect 桩:0.1.7 分支经它注册 configure 装配
132
+ effect(fn) {
133
+ const disposer = fn()
134
+ if (typeof disposer === 'function') effectDisposers.push(disposer)
135
+ },
136
+ })
137
+ }
119
138
  },
120
139
  }
121
140
  }
@@ -899,3 +918,96 @@ test('输入框历史按钮启停:GET 默认启用,POST 切换经 settings 持
899
918
  await handlers.get('/api/context/history-button-enabled')(getRequest2('/api/context/history-button-enabled', 'GET'), restored)
900
919
  assert.equal(restored.body.enabled, true)
901
920
  })
921
+
922
+ // --- 0.1.7 settings 面双形适配(maintain a913c2c 同构) ---
923
+
924
+ test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', () => {
925
+ // Given 静态 Config 导出(0.1.7 节表单事实源)
926
+ assert.ok(indexModule.Config, 'Config 导出必须在场')
927
+ // When 空配置校验
928
+ const resolved = indexModule.Config['~standard'].validate({})
929
+ // Then 产整节单 ref(get 协议),布尔开关默认全开(forkAutoResend 除外)
930
+ assert.equal(resolved.issues, undefined)
931
+ const section = resolved.value.get()
932
+ assert.equal(section.historyEnabled, true)
933
+ assert.equal(section.forkAutoResendEnabled, false)
934
+ assert.equal(section.copySidEnabled, true)
935
+ })
936
+
937
+ test('0.1.7 形态:settings 无 get/register 时读回落 config ref 节值', skipMissingDeps, async () => {
938
+ // Given settingsForms 只有 update(0.1.7 真实方法面)+ config ref 携带用户值
939
+ const settingsForms = { update: async () => {} }
940
+ const configRef = { get: () => ({ historyEnabled: false, copySidEnabled: false }) }
941
+ const { handlers } = makeCtx({
942
+ headers: [{ id: 's1', cwd: 'C:/x', createdAt: 0 }],
943
+ agents: new Map(),
944
+ settingsService: settingsForms,
945
+ configRef,
946
+ })
947
+ // When GET 开关路由
948
+ const res = response()
949
+ await handlers.get('/api/context/history-enabled')(getRequest2('/api/context/history-enabled', 'GET'), res)
950
+ // Then 返回 config 节值而非默认
951
+ assert.equal(res.body.enabled, false)
952
+ })
953
+
954
+ test('0.1.7 形态:开关 POST 经 configEditor.edit 落 fiber.entry 并回显新值', skipMissingDeps, async () => {
955
+ // Given settingsForms 只有 update + configEditor 桩(合并写回 configStore)
956
+ const settingsForms = { update: async () => {} }
957
+ const configStore = {}
958
+ const editCalls = []
959
+ const configRef = { get: () => ({ ...configStore }) }
960
+ const { handlers } = makeCtx({
961
+ headers: [{ id: 's1', cwd: 'C:/x', createdAt: 0 }],
962
+ agents: new Map(),
963
+ settingsService: settingsForms,
964
+ configRef,
965
+ configEditor: {
966
+ async edit(entry, change) {
967
+ editCalls.push(entry)
968
+ const next = change({ ...configStore }, {})
969
+ for (const [key, value] of Object.entries(next)) configStore[key] = value
970
+ },
971
+ },
972
+ })
973
+ // When POST 关闭历史浮层
974
+ const off = await postJson(handlers, '/api/context/history-enabled', { enabled: false })
975
+ // Then edit 定位本条目,合并写回,回显新值
976
+ assert.equal(off.body.enabled, false)
977
+ assert.equal(editCalls.length, 1)
978
+ assert.equal(editCalls[0].options.id, 'context-manager')
979
+ assert.equal(configStore.historyEnabled, false)
980
+ })
981
+
982
+ test('0.1.7 形态:configEditor 缺失即 400 拒写,配置不变', skipMissingDeps, async () => {
983
+ // Given settingsForms 只有 update 且无 configEditor
984
+ const settingsForms = { update: async () => {} }
985
+ const configRef = { get: () => ({}) }
986
+ const { handlers } = makeCtx({
987
+ headers: [{ id: 's1', cwd: 'C:/x', createdAt: 0 }],
988
+ agents: new Map(),
989
+ settingsService: settingsForms,
990
+ configRef,
991
+ })
992
+ // When POST 写开关
993
+ const denied = await postJson(handlers, '/api/context/history-enabled', { enabled: false })
994
+ // Then 400 错误响应
995
+ assert.equal(denied.status, 400)
996
+ assert.ok(denied.body.error)
997
+ })
998
+
999
+ test('0.1.7 形态:settings.configure 在场即关闭原生自动页,register 缺失不抛错', skipMissingDeps, () => {
1000
+ // Given settingsForms 带 configure 无 register
1001
+ const configureCalls = []
1002
+ const settingsForms = { configure: (presentation, owner) => { configureCalls.push({ presentation, owner }); return () => {} } }
1003
+ const { activateSettings } = makeCtx({
1004
+ headers: [{ id: 's1', cwd: 'C:/x', createdAt: 0 }],
1005
+ agents: new Map(),
1006
+ settingsService: settingsForms,
1007
+ })
1008
+ // When 激活 settings 注入
1009
+ activateSettings()
1010
+ // Then 关闭自动页且 owner 为本插件 fiber
1011
+ assert.equal(configureCalls.length, 1)
1012
+ assert.deepEqual(configureCalls[0].presentation, { auto: false })
1013
+ })