@mzzsfy/dsh-context-manager 0.1.0 → 0.2.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
@@ -1,6 +1,6 @@
1
1
  # dsh-context-manager
2
2
 
3
- 对话增强插件:历史输入回溯、插话撤回、对话分叉(家族版本切换/进行中轮分叉/自动重发)。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
3
+ 对话增强插件:历史输入回溯、插话撤回、对话分叉(家族版本切换/进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
4
4
 
5
5
  ## 功能
6
6
 
@@ -8,10 +8,10 @@
8
8
 
9
9
  输入框按 `Alt+↑` 唤起历史输入浮层:
10
10
 
11
- - 范围导航:`←/→` 切换 workspace / session / global / prompts 四范围,列表时间倒序
11
+ - 范围导航:`←/→` 按 常用(prompts)→ 当前会话(session)→ 本工作区(workspace)→ 全部工作区(global) 依次切换(边界停住),列表时间倒序
12
12
  - 搜索:输入即时过滤(IME 组合输入守卫,命中计数),`↑/↓` 选中、`Enter` 回填
13
- - 回填经宿主公共契约 `inputActions.setDraft`;`Esc` 两级退出(编辑态 → 浮层)
14
- - 编辑态:收藏(`C`)/重命名/删除常用提示词,数据落全局 `prompts.json`,与自动历史互不覆写
13
+ - 回填经宿主公共契约 `inputActions.setDraft`;`Esc` 两级退出(搜索词非空先清词,再按关闭浮层)
14
+ - 列表行悬停星标收藏/取消常用提示词;常用范围内点「编辑」进入编辑态(重命名/删除/新增),数据落全局 `prompts.json`,与自动历史互不覆写
15
15
 
16
16
  数据链:Host 聚合工作区缓存(`~/.dsh/historyPrompt/`,产物解压 + stat 指纹增量对齐)→ `/api/context/inputs` → 浮层。
17
17
 
@@ -21,7 +21,7 @@
21
21
 
22
22
  ### 对话分叉(fork,重写式)
23
23
 
24
- 用户输入气泡操作排的分叉按钮:分叉出新会话到该轮之前(该轮不带入子会话消息流),该轮的用户输入自动回填子会话输入框供编辑重发——与插话撤回同构,作用于 fork 场景,原会话不动。图标与官方分支按钮同源;官方「在新对话中分支」驻留轮尾操作排(分叉含该轮),两者语义互补、位置不重叠。
24
+ 用户输入气泡操作排的分叉按钮:分叉出新会话到该轮之前(该轮不带入子会话消息流),该轮的用户输入自动回填子会话输入框供编辑重发——与插话撤回同构,作用于 fork 场景,原会话消息流不动(进行中轮分叉会停止原会话该轮未完成的回复,见下)。图标与官方分支按钮同源;官方「在新对话中分支」驻留轮尾操作排(分叉含该轮),两者语义互补、位置不重叠。
25
25
 
26
26
  - 锚点:`remote.session.follow` 开场帧(回溯窗口 2000 事件)建轮号 → { 结束 seq, 该轮首问文本, open } 映射;重试轮 N 的分叉边界取轮 N-1 的 turn/end seq(宿主边界语义 inclusive,轮间切口合法)
27
27
  - 轮内插话不误取:文本取本轮首条携带非空文本的用户本人消息(文本块按行拼接),插件注入/纯图/空白轮不注入按钮
@@ -34,16 +34,25 @@
34
34
 
35
35
  用户输入气泡操作排的家族版本环:当前会话在同源分叉家族(parentSession 链)中的序位/成员数,`‹ ›` 在家族成员间直接跳转(`sessions.open`,纯切换不分叉)。
36
36
 
37
- - 家族判定:`sessions.list` 快照行的 `parentSessionId` 建链投影,断链视为独立根;单成员家族不注入
37
+ - 家族判定:`sessions.list` 快照行的 `parentId` 建链投影(wire 形态字段名为 `parentSessionId`,客户端快照行映射为 `parentId`),断链视为独立根;单成员家族不注入
38
38
  - 零新 RPC:数据源为既有会话列表快照,环序按 `updatedAt` 升序(与分叉先后一致)
39
+ - 启停:随「对话分叉」开关一并启停(环由同一 dock 注入),宿主缺 sessions.list 快照面不注入
40
+
41
+ ### 标题栏复制 sessionId
42
+
43
+ 会话标题栏(「标准模式」标签右侧)的复制按钮:点击复制当前会话 id 到剪贴板,便于粘贴给他人排查或脚本引用。
44
+
45
+ - 平时隐藏(opacity 占位),鼠标悬停标题栏任意位置或键盘聚焦时显示;`prefers-reduced-motion` 下关闭过渡
46
+ - 复制优先走 `navigator.clipboard.writeText`,非安全上下文(如 LAN http 访问)自动回退隐藏文本域 `execCommand`
47
+ - 挂官方 `conversation.session.header.actions` 插槽,成功/失败均经 toast 反馈
39
48
 
40
49
  ## 启停
41
50
 
42
- 设置页「插件」→「对话增强」卡片四开关(历史输入/插话撤回/对话分叉/分叉后自动重发),经宿主 settings 持久,变更刷新页面生效。浮层内 `S` 键或开关行亦可切换。
51
+ 设置页「插件」→「对话增强」卡片五开关(历史输入/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效;家族版本环随「对话分叉」开关一并启停。
43
52
 
44
53
  ## 依赖与门控
45
54
 
46
- - 宿主服务经 inject 声明(`slots`/`sessions`/`workspaces`/`remote`/`remote.session` 点分声明),namespace 未就绪整体保持未激活(干净禁用)
55
+ - client 半区宿主服务经 inject 声明(`slots`/`sessions`/`workspaces`/`remote`/`remote.session` 点分声明),namespace 未就绪整体保持未激活(干净禁用);host 半区 inject 为 `webServer`/`agents`/`sessionQuery`/`sessionPersistence`
47
56
  - 公共依赖 `@mzzsfy/dsh-toast` 可选消费(模块缺失通知通道置空)
48
57
  - 样式注入带 `data-plugin` 标记,开关为 `cx-switch` 形态(状态选择器锚定 `input[type="checkbox"]`)
49
58
 
@@ -57,5 +66,10 @@
57
66
  | `/api/context/steer-recall-enabled` | GET/POST | 插话撤回启停 |
58
67
  | `/api/context/fork-enabled` | GET/POST | 对话分叉启停 |
59
68
  | `/api/context/fork-auto-resend-enabled` | GET/POST | 分叉后自动重发启停 |
69
+ | `/api/context/copy-sid-enabled` | GET/POST | 标题栏复制 sessionId 启停 |
70
+
71
+ 设计文档:仓库根 `docs/上下文管理插件设计.md`
72
+
73
+ ## dsh 版本兼容
60
74
 
61
- 设计文档:`docs/上下文管理插件设计.md`
75
+ 三版本全部通过:样式注入 data-plugin 契约、Alt+↑ 历史浮层、对话 fork、设置「插件」页对话增强卡四开关、host 半区 API(history-enabled / fork-enabled)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
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.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -1,10 +1,12 @@
1
- // dsh-context-manager Client 半区:「插件」设置页可配置卡片(四启停开关)
2
- // + 历史输入浮层(Alt+↑ 唤起,范围导航/搜索/收藏回填) + 插话撤回 + 对话 fork。
1
+ // dsh-context-manager Client 半区:「插件」设置页可配置卡片(五启停开关)
2
+ // + 历史输入浮层(Alt+↑ 唤起,范围导航/搜索/收藏回填) + 插话撤回 + 对话 fork
3
+ // + 标题栏复制 sessionId(悬停显隐)。
3
4
  // 历史输入挂官方 conversation.input.dock 插槽(渲染为零高度锚点),回填走宿主公共
4
5
  // 契约 inputActions.setDraft;插话撤回注入官方 pending steering 气泡操作图标排;
5
6
  // fork 注入消息气泡操作排,分叉到该轮之前并把该轮用户输入回填子会话输入框(重试
6
7
  // 语义),RPC 走宿主 sessions 服务面(fork+open),锚点经 remote.session.follow
7
- // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射。浏览器半区经 webServer 路由
8
+ // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射;复制 sessionId 挂官方
9
+ // conversation.session.header.actions 插槽。浏览器半区经 webServer 路由
8
10
  // ('/api/context/*')访问 Host。打包为单文件自包含格式,无法跨文件 require;
9
11
  // 与 src/core.mjs 镜像的纯函数(filterHistoryInputs/forkFailureText/forkRetryText)
10
12
  // 修改需两处同步。
@@ -140,6 +142,18 @@ const CSS = [
140
142
  // dock 本体零 DOM,仅承载注入逻辑
141
143
  '.cx-steer-host { display:none; }',
142
144
  '.cx-fork-host { display:none; }',
145
+ // 标题栏复制 sessionId:与官方 icon 按钮同尺寸,默认隐藏(opacity 保占位,
146
+ // 隐形盒自身仍可悬停/聚焦),悬停标题栏任一位置或按钮自身/键盘聚焦时显示;
147
+ // 宿主 CSS module 类名带哈希前缀但名称后缀稳定,子串选择器跨构建匹配,
148
+ // :has 不可用时自身悬停兜底
149
+ '.cx-sid { appearance:none; border:0; background:transparent; cursor:pointer; flex:none;',
150
+ ' display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; padding:0;',
151
+ ' border-radius:6px; color:var(--dsw-alias-label-tertiary, rgba(127,127,127,.9));',
152
+ ' opacity:0; transition:opacity .15s, color .15s; }',
153
+ '.cx-sid:hover, .cx-sid:focus-visible { opacity:1; color:var(--dsw-alias-label-primary, inherit); }',
154
+ '.cx-sid:focus-visible { outline:2px solid #1677ff; outline-offset:1px; }',
155
+ '[class*="_header"]:has(.cx-sid):hover .cx-sid, [class*="_header"]:has(.cx-sid):focus-within .cx-sid { opacity:1; }',
156
+ '@media (prefers-reduced-motion: reduce) { .cx-sid { transition:none; } }',
143
157
  ].join('\n')
144
158
 
145
159
  const INPUTS_URL = '/api/context/inputs'
@@ -148,6 +162,7 @@ const HISTORY_ENABLED_URL = '/api/context/history-enabled'
148
162
  const STEER_ENABLED_URL = '/api/context/steer-recall-enabled'
149
163
  const FORK_ENABLED_URL = '/api/context/fork-enabled'
150
164
  const FORK_AUTO_RESEND_URL = '/api/context/fork-auto-resend-enabled'
165
+ const COPY_SID_ENABLED_URL = '/api/context/copy-sid-enabled'
151
166
 
152
167
  // 请求默认超时:host 被批量解压等同步任务阻塞时路由会迟滞数秒,
153
168
  // 无超时则浮层停在「正在读取…」假死;超时按错误抛出,由调用方兜底,
@@ -1128,6 +1143,62 @@ const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,
1128
1143
  const STEER_SWITCH_TITLE = '插话发送后、尚未被智能体应用期间,在该插话气泡的操作图标排显示撤回按钮,点击撤回并把原文填回输入框(覆盖输入框现有草稿);含附件的插话不可撤回;消息被应用后按钮随气泡消失,恰在应用瞬间点击会提示已应用且不动草稿。停用即不再注入,刷新页面生效。'
1129
1144
  const FORK_SWITCH_TITLE = '消息气泡操作排显示分叉按钮,点击分叉出新会话到该轮之前(该轮不带入子会话),该轮的用户输入自动回填子会话输入框供编辑重发,子会话自动打开且标题尾号递增;进行中的轮(回复尚未完成)同样可分叉,分叉后自动停止本会话该轮未完成的回复;首轮(无更早上下文可继承,新建会话即为同义操作)与无文本输入的轮(纯图等,无从重发)不注入;停用即不再注入,刷新页面生效。'
1130
1145
  const FORK_AUTO_RESEND_SWITCH_TITLE = '分叉成功后自动把该轮原输入发送到子会话立即开跑(重生成语义,相当于原输入重跑一遍);关闭时分叉仅把原输入回填子会话输入框,由你编辑后再手动发送。'
1146
+ const COPY_SID_SWITCH_TITLE = '会话标题栏显示复制 sessionId 按钮,平时隐藏,鼠标移到标题栏任意位置时显示,点击复制当前会话 id 到剪贴板(便于粘贴给他人排查或脚本引用);关闭后按钮整体不渲染,刷新页面生效。'
1147
+
1148
+ // 复制动作(纯函数,剪贴板与文档依赖注入):clipboard.writeText 优先,缺失或
1149
+ // 抛错(LAN http 非安全上下文无 navigator.clipboard)回退隐藏文本域 execCommand;
1150
+ // 全部失败返回 false,由调用方 toast 反馈
1151
+ async function copySidText(text, clipboard, doc) {
1152
+ if (clipboard && typeof clipboard.writeText === 'function') {
1153
+ try {
1154
+ await clipboard.writeText(text)
1155
+ return true
1156
+ } catch {
1157
+ // 非安全上下文拒绝等,落回退路径
1158
+ }
1159
+ }
1160
+ try {
1161
+ const area = doc.createElement('textarea')
1162
+ area.value = text
1163
+ area.setAttribute('readonly', '')
1164
+ area.style.position = 'fixed'
1165
+ area.style.opacity = '0'
1166
+ doc.body.appendChild(area)
1167
+ try {
1168
+ area.select()
1169
+ return doc.execCommand('copy') === true
1170
+ } finally {
1171
+ // 含会话 id 的临时节点必须无条件清理,异常路径不得残留 DOM
1172
+ area.remove()
1173
+ }
1174
+ } catch {
1175
+ return false
1176
+ }
1177
+ }
1178
+
1179
+ // 标题栏复制 sessionId 按钮:挂官方 conversation.session.header.actions 插槽
1180
+ // (标准模式标签右侧,sessionId 由插槽 standardProps 下发),开关停用即整体
1181
+ // 不渲染;启停读取失败按启用兜底(与 HistoryDock/switchRow 一致)
1182
+ function CopySidButton({ sessionId }) {
1183
+ const [enabled, setEnabled] = useState(true)
1184
+ useEffect(() => {
1185
+ api(COPY_SID_ENABLED_URL)
1186
+ .then((payload) => setEnabled(payload ? payload.enabled !== false : true))
1187
+ .catch(() => {})
1188
+ }, [])
1189
+ if (!sessionId || !enabled) return null
1190
+ // copySidText 为 async 函数(内部全路径吞异常),必 resolve,失败以 false 表达
1191
+ const copy = () => {
1192
+ copySidText(sessionId, navigator.clipboard, document)
1193
+ .then((ok) => toast(ok ? '已复制 sessionId' : '复制失败', ok ? undefined : { kind: 'error' }))
1194
+ }
1195
+ return h('button', { type: 'button', className: 'cx-sid', title: '复制 sessionId', 'aria-label': '复制 sessionId', onClick: copy },
1196
+ h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
1197
+ h('rect', { x: 5.75, y: 5.75, width: 7.5, height: 7.5, rx: 1.5, stroke: 'currentColor', 'stroke-width': '1.2' }),
1198
+ h('path', { d: 'M10.25 3.25H4.25a1.5 1.5 0 0 0-1.5 1.5v6', stroke: 'currentColor', 'stroke-width': '1.2', 'stroke-linecap': 'round' }),
1199
+ ),
1200
+ )
1201
+ }
1131
1202
 
1132
1203
  // 启停开关行工厂:三个开关同构(受控 checkbox + cx-switch 形态),值存宿主 settings,
1133
1204
  // 切换经本插件路由中转,变更刷新页面生效
@@ -1166,6 +1237,7 @@ const HistorySwitchRow = switchRow(HISTORY_ENABLED_URL, '历史输入浮层(Alt+
1166
1237
  const SteerSwitchRow = switchRow(STEER_ENABLED_URL, '插话撤回', STEER_SWITCH_TITLE, '插话撤回')
1167
1238
  const ForkSwitchRow = switchRow(FORK_ENABLED_URL, '对话 fork', FORK_SWITCH_TITLE, '对话 fork')
1168
1239
  const ForkAutoResendSwitchRow = switchRow(FORK_AUTO_RESEND_URL, '分叉后自动重发', FORK_AUTO_RESEND_SWITCH_TITLE, '分叉后自动重发')
1240
+ const CopySidSwitchRow = switchRow(COPY_SID_ENABLED_URL, '标题栏复制 sessionId', COPY_SID_SWITCH_TITLE, '标题栏复制 sessionId')
1169
1241
 
1170
1242
  // 「插件」设置页卡片:与官方 PluginCard 同款折叠卡(header 展开/收起 + body),
1171
1243
  // 视觉值取自宿主实测(见 CSS 注释),组件因缺宿主 i18n/表单状态而自绘
@@ -1189,6 +1261,7 @@ function ContextPanel() {
1189
1261
  h(SteerSwitchRow),
1190
1262
  h(ForkSwitchRow),
1191
1263
  h(ForkAutoResendSwitchRow),
1264
+ h(CopySidSwitchRow),
1192
1265
  ),
1193
1266
  )
1194
1267
  }
@@ -1354,6 +1427,20 @@ function ContextPanel() {
1354
1427
  console.warn('[context-manager] 对话 fork 入口未注册(宿主无 conversation.input.dock 插槽)', error)
1355
1428
  }
1356
1429
  }
1430
+
1431
+ // 标题栏复制 sessionId 入口:官方 conversation.session.header.actions 插槽
1432
+ // (list,order -9 紧随官方 agent-preset 标签 -10,即「标准模式」右侧);
1433
+ // sessionId 由该插槽 standardProps 自动下发(与官方 agent-preset 标签同源),
1434
+ // 组件缺失时自行判空不渲染。宿主缺该插槽时仅禁用本功能,不阻塞其余能力
1435
+ try {
1436
+ ctx.slots.inject('conversation.session.header.actions', () =>
1437
+ ctx.slots.register(
1438
+ { name: 'conversation.session.header.actions', id: 'context-manager-copy-sid', order: -9 },
1439
+ CopySidButton,
1440
+ ))
1441
+ } catch (error) {
1442
+ console.warn('[context-manager] 复制 sessionId 入口未注册(宿主无 conversation.session.header.actions 插槽)', error)
1443
+ }
1357
1444
  },
1358
1445
  }
1359
1446
  },
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
- // dsh-context-manager Host 半区:历史输入工作区缓存对齐 + 五条路由
2
- // (inputs / prompts/toggle / history-enabled / steer-recall-enabled / fork-enabled)。
1
+ // dsh-context-manager Host 半区:历史输入工作区缓存对齐 + 启停/数据路由
2
+ // (inputs / prompts/toggle 与 history/steer-recall/fork/fork-auto-resend/copy-sid
3
+ // 五条启停)。
3
4
  // 浮层请求直接读缓存文件立即返回;对齐在后台解压范围内会话产物与缓存合并写回,
4
5
  // 运行中会话也参与(实时追加其新输入)。fork 分叉动作走 client 服务面,
5
6
  // host 侧只有其启停路由。
@@ -53,6 +54,8 @@ const SETTINGS_SCHEMA = schemastery.object({
53
54
  .description('消息气泡操作排的分叉按钮,关闭后按钮整体不渲染;变更刷新页面生效'),
54
55
  forkAutoResendEnabled: schemastery.boolean().default(false)
55
56
  .description('分叉成功后自动把该轮原输入发送到子会话(重生成语义);关闭时仅回填子会话输入框供编辑重发'),
57
+ copySidEnabled: schemastery.boolean().default(true)
58
+ .description('会话标题栏的复制 sessionId 按钮,平时隐藏,鼠标悬停标题栏时显示,点击复制当前会话 id;关闭后按钮整体不渲染;变更刷新页面生效'),
56
59
  })
57
60
 
58
61
  function sendJson(res, status, payload) {
@@ -79,6 +82,7 @@ function readSettings(ctx) {
79
82
  steerRecallEnabled: !value || value.steerRecallEnabled !== false,
80
83
  forkEnabled: !value || value.forkEnabled !== false,
81
84
  forkAutoResendEnabled: Boolean(value && value.forkAutoResendEnabled === true),
85
+ copySidEnabled: !value || value.copySidEnabled !== false,
82
86
  }
83
87
  }
84
88
 
@@ -539,6 +543,31 @@ export function apply(ctx, config) {
539
543
  }
540
544
  },
541
545
  },
546
+ {
547
+ // 标题栏复制 sessionId 启停:与历史浮层开关同构,读走 settings,写经 settings.update
548
+ path: '/api/context/copy-sid-enabled',
549
+ handler: async (req, res) => {
550
+ try {
551
+ if (req.method === 'GET') {
552
+ sendJson(res, 200, { enabled: readSettings(ctx).copySidEnabled })
553
+ return
554
+ }
555
+ if (!rejectMethod(req, res, 'POST')) return
556
+ let body
557
+ try {
558
+ body = JSON.parse(await readBody(req))
559
+ } catch {
560
+ throw new Error(MESSAGES.badJsonBody)
561
+ }
562
+ const settings = ctx.get('settings')
563
+ if (!settings) throw new Error('宿主设置服务不可用')
564
+ await settings.update(NAMESPACE, { copySidEnabled: Boolean(body && body.enabled) })
565
+ sendJson(res, 200, { enabled: readSettings(ctx).copySidEnabled })
566
+ } catch (error) {
567
+ respondError(ctx, res, error)
568
+ }
569
+ },
570
+ },
542
571
  ]
543
572
 
544
573
  for (const route of routes) {
@@ -0,0 +1,252 @@
1
+ // 标题栏复制 sessionId:接线与门控测试。加载真实 src/client.js,断言 header.actions
2
+ // 插槽注册、inject 契约、开关门控(停用不渲染)与复制动作(toast 反馈);
3
+ // 复制本体 copySidText 为纯函数切片(剪贴板/文档依赖注入)。
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
9
+
10
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
11
+ const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
12
+
13
+ // fetch 桩:按 URL 返回预设 payload;未预设的 URL 抛错(暴露意外请求)
14
+ function withFetchStub(routes, run) {
15
+ const original = globalThis.fetch
16
+ globalThis.fetch = async (url) => {
17
+ const hit = routes.find((route) => String(url).startsWith(route.path))
18
+ if (!hit) throw new Error('意外请求: ' + String(url))
19
+ if (hit.reject) throw new Error('network down')
20
+ return { ok: true, status: 200, json: async () => hit.body }
21
+ }
22
+ return Promise.resolve()
23
+ .then(run)
24
+ .finally(() => { globalThis.fetch = original })
25
+ }
26
+
27
+ // client.js 装载:react 桩为可驱动微型模拟(useState 槽位 / useEffect 收集 /
28
+ // createElement 记录),apply 后返回捕获的插槽注册表
29
+ function loadClientHarness({ documentStub } = {}) {
30
+ const modules = []
31
+ const registered = []
32
+ const toasts = []
33
+ const elements = []
34
+ const hookSlots = []
35
+ const effects = []
36
+ // hook 游标:每次渲染前置 0,useState 按调用序读写槽位(模拟 React hook 链)
37
+ let hookCursor = 0
38
+ const reactStub = {
39
+ useState: (init) => {
40
+ let cell = hookSlots[hookCursor]
41
+ if (!cell) {
42
+ cell = { value: init }
43
+ hookSlots[hookCursor] = cell
44
+ }
45
+ hookCursor += 1
46
+ return [cell.value, (next) => { cell.value = typeof next === 'function' ? next(cell.value) : next }]
47
+ },
48
+ useEffect: (fn) => { effects.push(fn) },
49
+ useRef: (value) => ({ current: value }),
50
+ createElement: (type, props, ...children) => {
51
+ const element = { type, props: props || {}, children }
52
+ elements.push(element)
53
+ return element
54
+ },
55
+ }
56
+ const requireStub = (name) => {
57
+ if (name === '@mzzsfy/dsh-toast/client') return { show: (text, opts) => toasts.push({ text, opts }) }
58
+ return reactStub
59
+ }
60
+ const document = documentStub || {
61
+ createElement: () => ({ style: {}, remove() {} }),
62
+ head: { appendChild: () => {} },
63
+ body: { appendChild: () => {} },
64
+ }
65
+ const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
66
+ factory({ __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }, requireStub, document)
67
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
68
+ const mod = modules[0].factory(requireStub)
69
+ const sessions = { binding: () => undefined, fork: () => Promise.resolve('child-1'), open: () => {} }
70
+ const ctx = {
71
+ get: (name) => (name === 'sessions' ? sessions : {}),
72
+ effect: () => {},
73
+ slots: {
74
+ inject: (name, register) => register(),
75
+ register: (options, component) => registered.push({ options, component }),
76
+ },
77
+ remote: { session: { follow: () => (async function* () { yield { type: 'snapshot', cursor: 0, records: [] }; await new Promise(() => {}) })() } },
78
+ }
79
+ mod.apply(ctx)
80
+ return {
81
+ registered,
82
+ toasts,
83
+ // 首渲染:清空 hook 槽位与效果队列后跑组件(全新挂载);效果同步执行
84
+ render(component, props) {
85
+ effects.length = 0
86
+ elements.length = 0
87
+ hookSlots.length = 0
88
+ hookCursor = 0
89
+ const output = component(props)
90
+ for (const effect of effects.splice(0)) effect()
91
+ return output
92
+ },
93
+ // 重渲染:保留 hook 槽位(setter 已写入的状态生效),不重跑效果——忠实
94
+ // React 空 deps effect 只跑一次的语义
95
+ rerender(component, props) {
96
+ elements.length = 0
97
+ hookCursor = 0
98
+ return component(props)
99
+ },
100
+ async settle() { await new Promise((resolve) => setTimeout(resolve, 0)) },
101
+ }
102
+ }
103
+
104
+ function findCopySidEntry(harness) {
105
+ const entry = harness.registered.find((item) => item.options.id === 'context-manager-copy-sid')
106
+ assert.ok(entry, '未注册 id=context-manager-copy-sid 的标题栏条目')
107
+ return entry
108
+ }
109
+
110
+ test('Given client.js 装载, When apply, Then header.actions 注册复制条目', () => {
111
+ const harness = loadClientHarness()
112
+ const entry = findCopySidEntry(harness)
113
+ assert.equal(entry.options.name, 'conversation.session.header.actions', '应注册进标题栏 actions 插槽')
114
+ assert.equal(entry.options.order, -9, '应紧随官方 agent-preset 标签(-10)之后')
115
+ assert.equal(typeof entry.component, 'function', '条目缺组件')
116
+ assert.equal(entry.options.inject, undefined, 'sessionId 由插槽 standardProps 下发,不需 inject')
117
+ })
118
+
119
+ test('Given 开关启用, When 渲染, Then 渲染 cx-sid 复制按钮', async () => {
120
+ const harness = loadClientHarness()
121
+ const { component } = findCopySidEntry(harness)
122
+ let output
123
+ await withFetchStub([{ path: '/api/context/copy-sid-enabled', body: { enabled: true } }], async () => {
124
+ output = harness.render(component, { sessionId: 's1' })
125
+ await harness.settle()
126
+ })
127
+ output = harness.rerender(component, { sessionId: 's1' })
128
+ assert.equal(output.type, 'button', '启用时应渲染按钮')
129
+ assert.equal(output.props.className, 'cx-sid')
130
+ assert.equal(output.props.title, '复制 sessionId')
131
+ assert.equal(output.props['aria-label'], '复制 sessionId')
132
+ })
133
+
134
+ test('Given 开关停用, When 渲染, Then 不渲染(整体 null)', async () => {
135
+ const harness = loadClientHarness()
136
+ const { component } = findCopySidEntry(harness)
137
+ let output
138
+ await withFetchStub([{ path: '/api/context/copy-sid-enabled', body: { enabled: false } }], async () => {
139
+ output = harness.render(component, { sessionId: 's1' })
140
+ await harness.settle()
141
+ })
142
+ output = harness.rerender(component, { sessionId: 's1' })
143
+ assert.equal(output, null, '停用后按钮整体不渲染')
144
+ })
145
+
146
+ test('Given 启停读取失败, When 渲染, Then 按启用兜底(fail-open,与既有开关一致)', async () => {
147
+ const harness = loadClientHarness()
148
+ const { component } = findCopySidEntry(harness)
149
+ await withFetchStub([{ path: '/api/context/copy-sid-enabled', reject: true }], async () => {
150
+ harness.render(component, { sessionId: 's1' })
151
+ await harness.settle()
152
+ })
153
+ const output = harness.rerender(component, { sessionId: 's1' })
154
+ assert.equal(output.type, 'button', '读取失败应按启用兜底')
155
+ })
156
+
157
+ test('Given 无 sessionId, When 渲染, Then 不渲染', () => {
158
+ const harness = loadClientHarness()
159
+ const { component } = findCopySidEntry(harness)
160
+ const output = harness.render(component, {})
161
+ assert.equal(output, null, '无会话 id 应不渲染')
162
+ })
163
+
164
+ // ── 纯函数切片:copySidText(剪贴板优先,execCommand 回退) ──
165
+
166
+ const PURE_START = CLIENT_SRC.indexOf('async function copySidText(')
167
+ const PURE_END = CLIENT_SRC.indexOf('function CopySidButton(')
168
+ assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js copySidText 切片定位失败')
169
+ const { copySidText } = new Function(CLIENT_SRC.slice(PURE_START, PURE_END) + '; return { copySidText }')()
170
+
171
+ const FAKE_DOC = (execResult, execThrows) => {
172
+ const appended = []
173
+ return {
174
+ appended,
175
+ createElement: () => ({ style: {}, value: '', setAttribute() {}, select() {}, remove() { appended.push('removed') } }),
176
+ body: { appendChild: (node) => appended.push(node) },
177
+ execCommand: () => { if (execThrows) throw new Error('exec down'); return execResult },
178
+ }
179
+ }
180
+
181
+ test('copySidText:clipboard.writeText 可用即成功', async () => {
182
+ const writes = []
183
+ const done = await copySidText('s1', { writeText: async (text) => { writes.push(text) } }, FAKE_DOC(true))
184
+ assert.equal(done, true)
185
+ assert.deepEqual(writes, ['s1'])
186
+ })
187
+
188
+ test('copySidText:writeText 抛错回退 execCommand 成功', async () => {
189
+ const doc = FAKE_DOC(true)
190
+ const done = await copySidText('s1', { writeText: async () => { throw new Error('denied') } }, doc)
191
+ assert.equal(done, true)
192
+ // 临时文本域被 append 且随后移除(无残留)
193
+ assert.equal(doc.appended.filter((node) => typeof node === 'string' && node === 'removed').length, 1, '回退路径应清理临时文本域')
194
+ assert.equal(doc.appended.length, 2, 'append 后必须紧跟 remove')
195
+ })
196
+
197
+ test('copySidText:无 clipboard 时走 execCommand 回退', async () => {
198
+ assert.equal(await copySidText('s1', undefined, FAKE_DOC(true)), true)
199
+ assert.equal(await copySidText('s1', undefined, FAKE_DOC(false)), false)
200
+ const throwing = FAKE_DOC(null, true)
201
+ assert.equal(await copySidText('s1', undefined, throwing), false)
202
+ // finally 清理语义:execCommand 抛错路径临时节点同样必须移除,不得残留 DOM
203
+ assert.deepEqual(
204
+ throwing.appended.filter((entry) => entry === 'removed').length,
205
+ throwing.appended.filter((entry) => typeof entry !== 'string').length,
206
+ '每个 append 的临时节点都必须紧跟一次 remove',
207
+ )
208
+ })
209
+
210
+ // ── 点击动作:toast 反馈 ──
211
+
212
+ test('Given 点击按钮, When 复制成功, Then toast 已复制 sessionId', async () => {
213
+ const harness = loadClientHarness({ documentStub: FAKE_DOC(true) })
214
+ const { component } = findCopySidEntry(harness)
215
+ let output
216
+ await withFetchStub([{ path: '/api/context/copy-sid-enabled', body: { enabled: true } }], async () => {
217
+ output = harness.render(component, { sessionId: 's1' })
218
+ await harness.settle()
219
+ })
220
+ output = harness.rerender(component, { sessionId: 's1' })
221
+ output.props.onClick()
222
+ await harness.settle()
223
+ assert.deepEqual(harness.toasts, [{ text: '已复制 sessionId', opts: undefined }])
224
+ })
225
+
226
+ test('Given 点击按钮, When 复制失败, Then toast 复制失败(error)且不误报成功', async () => {
227
+ const harness = loadClientHarness({ documentStub: FAKE_DOC(false) })
228
+ const { component } = findCopySidEntry(harness)
229
+ let output
230
+ await withFetchStub([{ path: '/api/context/copy-sid-enabled', body: { enabled: true } }], async () => {
231
+ output = harness.render(component, { sessionId: 's1' })
232
+ await harness.settle()
233
+ })
234
+ output = harness.rerender(component, { sessionId: 's1' })
235
+ output.props.onClick()
236
+ await harness.settle()
237
+ assert.deepEqual(harness.toasts, [{ text: '复制失败', opts: { kind: 'error' } }])
238
+ })
239
+
240
+ // ── 源码契约:显隐 CSS 与设置卡开关行 ──
241
+
242
+ test('CSS 显隐契约:默认隐藏,整行悬停/自身悬停/键盘聚焦显示,reduced-motion 关过渡', () => {
243
+ assert.match(CLIENT_SRC, /\.cx-sid \{[^}]*opacity:0/, '按钮默认必须隐藏')
244
+ assert.match(CLIENT_SRC, /\[class\*="_header"\]:has\(\.cx-sid\):hover \.cx-sid/, '标题栏整行悬停应显示按钮')
245
+ assert.match(CLIENT_SRC, /\.cx-sid:hover, \.cx-sid:focus-visible \{ opacity:1/, '自身悬停与键盘聚焦必须兜底显示')
246
+ assert.match(CLIENT_SRC, /@media \(prefers-reduced-motion: reduce\) \{ \.cx-sid \{ transition:none/, 'reduced-motion 应关闭过渡')
247
+ })
248
+
249
+ test('设置卡契约:第五开关行走 switchRow 工厂,挂 copy-sid-enabled 路由', () => {
250
+ assert.ok(CLIENT_SRC.includes("switchRow(COPY_SID_ENABLED_URL, '标题栏复制 sessionId'"), '设置卡缺复制 sessionId 开关行')
251
+ assert.ok(CLIENT_SRC.includes('h(CopySidSwitchRow)'), 'ContextPanel 未挂载第五开关行')
252
+ })
@@ -661,12 +661,12 @@ test('常用提示词:超长截断、上限裁剪、空 text 拒绝', skipMissin
661
661
 
662
662
  // ── 路由层横切守卫:方法白名单 / JSON 体 / 系统级错误收敛 / 非字符串 text ──
663
663
 
664
- test('横切守卫:5 条路由方法白名单,白名单外一律 405', skipMissingDeps, async () => {
664
+ test('横切守卫:6 条路由方法白名单,白名单外一律 405', skipMissingDeps, async () => {
665
665
  await withHistoryCacheDir(async () => {
666
666
  const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
667
667
  const getOnly = '/api/context/inputs'
668
668
  const postOnly = '/api/context/prompts/toggle'
669
- const dualMethods = ['/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled']
669
+ const dualMethods = ['/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled', '/api/context/copy-sid-enabled']
670
670
  // GET-only 路由:POST 拒绝
671
671
  const inputsPost = response()
672
672
  await handlers.get(getOnly)(request('s1'), inputsPost)
@@ -684,10 +684,10 @@ test('横切守卫:5 条路由方法白名单,白名单外一律 405', skipMissi
684
684
  })
685
685
  })
686
686
 
687
- test('横切守卫:4 条 POST 路由的非法 JSON 请求体统一 400(badJsonBody)', skipMissingDeps, async () => {
687
+ test('横切守卫:5 条 POST 路由的非法 JSON 请求体统一 400(badJsonBody)', skipMissingDeps, async () => {
688
688
  await withHistoryCacheDir(async () => {
689
689
  const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
690
- const postPaths = ['/api/context/prompts/toggle', '/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled']
690
+ const postPaths = ['/api/context/prompts/toggle', '/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled', '/api/context/copy-sid-enabled']
691
691
  for (const routePath of postPaths) {
692
692
  const res = await postRaw(handlers, routePath, '{oops')
693
693
  assert.equal(res.status, 400, routePath + ' 非法 JSON 应回 400')
@@ -788,3 +788,19 @@ test('fork 启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新
788
788
  await handlers.get('/api/context/fork-enabled')(getRequest2('/api/context/fork-enabled', 'GET'), restored)
789
789
  assert.equal(restored.body.enabled, true)
790
790
  })
791
+
792
+ test('标题栏复制 sessionId 启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新值', skipMissingDeps, async () => {
793
+ const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
794
+ const initial = response()
795
+ await handlers.get('/api/context/copy-sid-enabled')(getRequest2('/api/context/copy-sid-enabled', 'GET'), initial)
796
+ assert.equal(initial.body.enabled, true, '默认启用')
797
+ const off = await postJson(handlers, '/api/context/copy-sid-enabled', { enabled: false })
798
+ assert.equal(off.body.enabled, false)
799
+ const reread = response()
800
+ await handlers.get('/api/context/copy-sid-enabled')(getRequest2('/api/context/copy-sid-enabled', 'GET'), reread)
801
+ assert.equal(reread.body.enabled, false, 'settings 持久化关闭态')
802
+ await postJson(handlers, '/api/context/copy-sid-enabled', { enabled: true })
803
+ const restored = response()
804
+ await handlers.get('/api/context/copy-sid-enabled')(getRequest2('/api/context/copy-sid-enabled', 'GET'), restored)
805
+ assert.equal(restored.body.enabled, true)
806
+ })