@mzzsfy/dsh-context-manager 0.2.0 → 0.3.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,14 +1,15 @@
1
1
  # dsh-context-manager
2
2
 
3
- 对话增强插件:历史输入回溯、插话撤回、对话分叉(家族版本切换/进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
3
+ 对话增强插件:历史输入回溯(Alt+↑ 或输入框工具排按钮唤起)、插话撤回、对话分叉(进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
4
4
 
5
5
  ## 功能
6
6
 
7
- ### 历史输入浮层(Alt+↑)
7
+ ### 历史输入浮层(Alt+↑ 或输入框按钮)
8
8
 
9
- 输入框按 `Alt+↑` 唤起历史输入浮层:
9
+ 输入框按 `Alt+↑`,或点击输入框下方工具排的历史按钮(指令/附件右侧,与 Alt+↑ 等效,再点关闭)唤起历史输入浮层:
10
10
 
11
11
  - 范围导航:`←/→` 按 常用(prompts)→ 当前会话(session)→ 本工作区(workspace)→ 全部工作区(global) 依次切换(边界停住),列表时间倒序
12
+ - 收录范围:普通输入与斜杠命令(如 `/goal …`,按会话日志的 command/run 重组为完整命令行),工具结果回填与插件注入不收录
12
13
  - 搜索:输入即时过滤(IME 组合输入守卫,命中计数),`↑/↓` 选中、`Enter` 回填
13
14
  - 回填经宿主公共契约 `inputActions.setDraft`;`Esc` 两级退出(搜索词非空先清词,再按关闭浮层)
14
15
  - 列表行悬停星标收藏/取消常用提示词;常用范围内点「编辑」进入编辑态(重命名/删除/新增),数据落全局 `prompts.json`,与自动历史互不覆写
@@ -30,14 +31,6 @@
30
31
  - 回填时序:宿主 open 可能先于草稿登记完成子会话挂载,消费侧以有限次延迟重查兜底
31
32
  - 分叉后自动重发(开关,默认关):开启时分叉成功即以该轮原输入经 `remote.session.prompt` 发送到子会话立即开跑(重生成语义);prompt 失败仅通知,文本已在输入框可手动发送;旧宿主缺 prompt 通道自动降级为仅回填
32
33
 
33
- ### 家族版本计数(‹ n/m ›)
34
-
35
- 用户输入气泡操作排的家族版本环:当前会话在同源分叉家族(parentSession 链)中的序位/成员数,`‹ ›` 在家族成员间直接跳转(`sessions.open`,纯切换不分叉)。
36
-
37
- - 家族判定:`sessions.list` 快照行的 `parentId` 建链投影(wire 形态字段名为 `parentSessionId`,客户端快照行映射为 `parentId`),断链视为独立根;单成员家族不注入
38
- - 零新 RPC:数据源为既有会话列表快照,环序按 `updatedAt` 升序(与分叉先后一致)
39
- - 启停:随「对话分叉」开关一并启停(环由同一 dock 注入),宿主缺 sessions.list 快照面不注入
40
-
41
34
  ### 标题栏复制 sessionId
42
35
 
43
36
  会话标题栏(「标准模式」标签右侧)的复制按钮:点击复制当前会话 id 到剪贴板,便于粘贴给他人排查或脚本引用。
@@ -48,7 +41,7 @@
48
41
 
49
42
  ## 启停
50
43
 
51
- 设置页「插件」→「对话增强」卡片五开关(历史输入/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效;家族版本环随「对话分叉」开关一并启停。
44
+ 设置页「插件」→「对话增强」卡片六开关(历史输入/输入框历史按钮/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效。「输入框历史按钮」仅控制工具排按钮显隐,停用后 Alt+↑ 与浮层不受影响。
52
45
 
53
46
  ## 依赖与门控
54
47
 
@@ -63,6 +56,7 @@
63
56
  | `/api/context/inputs` | GET | 历史输入(`?sessionId=&scope=`) |
64
57
  | `/api/context/prompts/toggle` | POST | 收藏/取消收藏(按截断后文本查重) |
65
58
  | `/api/context/history-enabled` | GET/POST | 历史浮层启停 |
59
+ | `/api/context/history-button-enabled` | GET/POST | 输入框历史按钮启停 |
66
60
  | `/api/context/steer-recall-enabled` | GET/POST | 插话撤回启停 |
67
61
  | `/api/context/fork-enabled` | GET/POST | 对话分叉启停 |
68
62
  | `/api/context/fork-auto-resend-enabled` | GET/POST | 分叉后自动重发启停 |
@@ -72,4 +66,4 @@
72
66
 
73
67
  ## dsh 版本兼容
74
68
 
75
- 三版本全部通过:样式注入 data-plugin 契约、Alt+↑ 历史浮层、对话 fork、设置「插件」页对话增强卡四开关、host 半区 API(history-enabled / fork-enabled)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
69
+ 三版本全部通过:样式注入 data-plugin 契约、历史输入浮层(Alt+↑ 或输入框按钮)、对话 fork、设置「插件」页对话增强卡开关组、host 半区 API(八条 `/api/context/*`)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-context-manager",
3
- "description": "对话增强:历史输入浮层(范围导航/搜索/subagent排除)、常用提示词收藏、插话撤回、对话 fork(家族版本切换/进行中轮分叉/自动重发)",
4
- "version": "0.2.0",
3
+ "description": "对话增强:历史输入浮层(范围导航/搜索/subagent排除)、常用提示词收藏、插话撤回、对话 fork(进行中轮分叉/自动重发)",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -1,11 +1,13 @@
1
- // dsh-context-manager Client 半区:「插件」设置页可配置卡片(五启停开关)
2
- // + 历史输入浮层(Alt+↑ 唤起,范围导航/搜索/收藏回填) + 插话撤回 + 对话 fork
1
+ // dsh-context-manager Client 半区:「插件」设置页可配置卡片(六启停开关)
2
+ // + 历史输入浮层(Alt+↑ 或输入框工具排按钮唤起,范围导航/搜索/收藏回填)
3
+ // + 插话撤回 + 对话 fork
3
4
  // + 标题栏复制 sessionId(悬停显隐)。
4
5
  // 历史输入挂官方 conversation.input.dock 插槽(渲染为零高度锚点),回填走宿主公共
5
6
  // 契约 inputActions.setDraft;插话撤回注入官方 pending steering 气泡操作图标排;
6
7
  // fork 注入消息气泡操作排,分叉到该轮之前并把该轮用户输入回填子会话输入框(重试
7
8
  // 语义),RPC 走宿主 sessions 服务面(fork+open),锚点经 remote.session.follow
8
- // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射;复制 sessionId 挂官方
9
+ // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射;回填前经官方 input 快照(useInput)
10
+ // 探测输入框占用,已被用户输入则不覆盖(权限高于回填);复制 sessionId 挂官方
9
11
  // conversation.session.header.actions 插槽。浏览器半区经 webServer 路由
10
12
  // ('/api/context/*')访问 Host。打包为单文件自包含格式,无法跨文件 require;
11
13
  // 与 src/core.mjs 镜像的纯函数(filterHistoryInputs/forkFailureText/forkRetryText)
@@ -34,11 +36,6 @@ window.__ModuleLoader__.load({
34
36
  }
35
37
 
36
38
  const CSS = [
37
- // 家族版本环:与官方 28px icon 按钮同高同隙,chevron 用官方线形 svg,
38
- // label 小号 caption 色,不另起风格
39
- '.cx-family-ring { display:inline-flex; align-items:center; gap:2px; }',
40
- '.cx-family-ring .cx-family-ring__label { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption);',
41
- ' min-width:24px; text-align:center; line-height:28px; }',
42
39
  // 开关行(规约 switch 形态):track 胶囊 + thumb 圆点,状态选择器锚定 checkbox
43
40
  '.cx-switch { display:inline-flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;',
44
41
  ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
@@ -159,6 +156,7 @@ const CSS = [
159
156
  const INPUTS_URL = '/api/context/inputs'
160
157
  const PROMPTS_TOGGLE_URL = '/api/context/prompts/toggle'
161
158
  const HISTORY_ENABLED_URL = '/api/context/history-enabled'
159
+ const HISTORY_BUTTON_ENABLED_URL = '/api/context/history-button-enabled'
162
160
  const STEER_ENABLED_URL = '/api/context/steer-recall-enabled'
163
161
  const FORK_ENABLED_URL = '/api/context/fork-enabled'
164
162
  const FORK_AUTO_RESEND_URL = '/api/context/fork-auto-resend-enabled'
@@ -241,48 +239,61 @@ function forkRetryText(data) {
241
239
  return text.trim() === '' ? null : text
242
240
  }
243
241
 
244
- // 家族谱系投影镜像(与 core.mjs sessionFamilyMap/familyRing 同步维护):
245
- // 客户端快照行主键为 id、父引用为 parentId(与 RPC wire 的 sessionId/parentSessionId 不同名),
246
- // 编排派生会话(origin === 'subagent')剔除——家族只统计用户的 fork 分支;
247
- // 断链视为独立根;环序按 updatedAt 升序
248
- function sessionFamilyMap(items) {
249
- const byId = new Map()
250
- for (const item of Array.isArray(items) ? items : []) {
251
- if (item && item.origin === 'subagent') continue
252
- const id = item && item.id
253
- if (typeof id === 'string' && id !== '') byId.set(id, item)
254
- }
255
- const chains = new Map()
256
- for (const id of byId.keys()) {
257
- const chain = []
258
- let cursor = id
259
- let root = cursor
260
- while (cursor !== undefined) {
261
- chain.unshift(cursor)
262
- root = cursor
263
- const parent = byId.get(cursor)
264
- cursor = parent && typeof parent.parentId === 'string' && byId.has(parent.parentId)
265
- ? parent.parentId
266
- : undefined
267
- if (chain.includes(cursor)) break
268
- }
269
- chains.set(id, { root, chain, item: byId.get(id) })
242
+ // ── 输入框历史按钮:注入官方输入工具排,点击开关浮层(与 Alt+↑ 等效)──
243
+
244
+ // 注入标记:识别自家节点,防重复注入;停用/卸载按标记全量移除
245
+ const HISTORY_BTN_FLAG = 'data-cx-hist-trigger'
246
+ // 官方输入工具排定位锚点:行类名后缀 + 官方 _add 图标按钮,双锚点一致才认;
247
+ // 官方类名漂移时寻行返回 null,注入静默跳过(干净禁用)
248
+ const TOOLS_ROW_SUFFIX = '_tools'
249
+ // 按钮悬停说明:点击与 Alt+↑ 等效
250
+ const HISTORY_BUTTON_TITLE = '历史输入(Alt+↑)'
251
+
252
+ // 官方输入工具排寻行:遍历官方 _add 图标按钮取其所属行
253
+ function findToolsRow(doc) {
254
+ for (const button of doc.querySelectorAll('button[class$="_add"]')) {
255
+ const row = button.parentElement
256
+ if (row && typeof row.className === 'string' && row.className.endsWith(TOOLS_ROW_SUFFIX)) return row
270
257
  }
271
- return chains
258
+ return null
272
259
  }
273
260
 
274
- function familyRing(chains, sessionId) {
275
- const entry = chains instanceof Map ? chains.get(sessionId) : undefined
276
- if (!entry) return null
277
- const members = [...chains.entries()]
278
- .filter(([, value]) => value.root === entry.root)
279
- .sort((left, right) => {
280
- const at = (record) => (record && record.item && typeof record.item.updatedAt === 'number' ? record.item.updatedAt : 0)
281
- return at(left[1]) - at(right[1])
282
- })
283
- .map(([id]) => id)
284
- if (members.length < 2) return null
285
- return { index: members.indexOf(sessionId) + 1, total: members.length, members }
261
+ // 历史图标 svg(时钟回拨):fill 继承 currentColor,视觉重量与官方 14px 图标一致
262
+ function histButtonSvg(doc) {
263
+ const namespace = 'http://www.w3.org/2000/svg'
264
+ const svg = doc.createElementNS(namespace, 'svg')
265
+ svg.setAttribute('width', '14')
266
+ svg.setAttribute('height', '14')
267
+ svg.setAttribute('viewBox', '0 0 24 24')
268
+ svg.setAttribute('fill', 'none')
269
+ svg.setAttribute('aria-hidden', 'true')
270
+ const path = doc.createElementNS(namespace, 'path')
271
+ path.setAttribute('fill', 'currentColor')
272
+ path.setAttribute('d', 'M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L8.85 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 16.99 10.51 18 13 18c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z')
273
+ svg.appendChild(path)
274
+ return svg
275
+ }
276
+
277
+ // 幂等注入:未注入时克隆官方 _add 类名创建按钮(尺寸/hover 全部原生),位置
278
+ // 固定在最后一个官方 _add 之后(与指令/附件同簇);已注入原样返回。
279
+ // doc 注入便于测试与宿主文档解耦
280
+ function ensureHistoryButton(doc, row, onToggle) {
281
+ const existing = row.querySelector('[' + HISTORY_BTN_FLAG + ']')
282
+ if (existing) return existing
283
+ const official = row.querySelector('button[class$="_add"]')
284
+ if (!official) return null
285
+ const button = doc.createElement('button')
286
+ button.type = 'button'
287
+ button.setAttribute(HISTORY_BTN_FLAG, '')
288
+ button.className = official.className
289
+ button.title = HISTORY_BUTTON_TITLE
290
+ button.setAttribute('aria-label', '历史输入')
291
+ button.addEventListener('click', onToggle)
292
+ button.appendChild(histButtonSvg(doc))
293
+ const officialButtons = row.querySelectorAll('button[class$="_add"]')
294
+ const last = officialButtons[officialButtons.length - 1]
295
+ row.insertBefore(button, (last && last.nextSibling) || null)
296
+ return button
286
297
  }
287
298
 
288
299
  function HistoryDock({ session, inputActions }) {
@@ -311,8 +322,13 @@ function HistoryDock({ session, inputActions }) {
311
322
  // 历史浮层启停(设置开关):挂载拉取一次,停用即整体不渲染、键盘放行
312
323
  const enabledRef = React.useRef(true)
313
324
  const [historyEnabled, setHistoryEnabled] = useState(true)
325
+ // 输入框历史按钮启停(设置开关):false 仅隐藏按钮,快捷键与浮层不受影响
326
+ const buttonEnabledRef = React.useRef(true)
327
+ const [buttonEnabled, setButtonEnabled] = useState(true)
314
328
  sessionRef.current = session
315
329
  inputActionsRef.current = inputActions
330
+ // 条件 return 之前同步:停用路径的扫描守卫也要看到最新值(与 SteerRecallDock 同构)
331
+ buttonEnabledRef.current = buttonEnabled
316
332
  useEffect(() => {
317
333
  api(HISTORY_ENABLED_URL)
318
334
  .then((payload) => {
@@ -321,6 +337,9 @@ function HistoryDock({ session, inputActions }) {
321
337
  setHistoryEnabled(on)
322
338
  })
323
339
  .catch(() => {})
340
+ api(HISTORY_BUTTON_ENABLED_URL)
341
+ .then((payload) => setButtonEnabled(payload ? payload.enabled !== false : true))
342
+ .catch(() => {})
324
343
  }, [])
325
344
 
326
345
  function syncView(patch) {
@@ -542,11 +561,15 @@ function HistoryDock({ session, inputActions }) {
542
561
  if (row) row.scrollIntoView({ block: 'nearest' })
543
562
  }, [open, cursor, items])
544
563
 
545
- // 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)
564
+ // 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)。
565
+ // 注入按钮在本容器外,closest 守卫跳过:否则 mousedown 先关、click 再开,
566
+ // 浮层开时点按钮等于常开,toggle 语义失效
546
567
  useEffect(() => {
547
568
  if (!open) return undefined
548
569
  function onPointerDown(event) {
549
- if (rootRef.current && !rootRef.current.contains(event.target)) {
570
+ const target = event.target
571
+ if (target && typeof target.closest === 'function' && target.closest('[' + HISTORY_BTN_FLAG + ']')) return
572
+ if (rootRef.current && !rootRef.current.contains(target)) {
550
573
  syncView({ open: false })
551
574
  setOpen(false)
552
575
  }
@@ -555,6 +578,49 @@ function HistoryDock({ session, inputActions }) {
555
578
  return () => document.removeEventListener('mousedown', onPointerDown, true)
556
579
  }, [open])
557
580
 
581
+ // 注入按钮点击:开→关,关→唤起;经 ref 接线,挂载一次的监听器恒读最新闭包
582
+ function toggleFromButton() {
583
+ if (viewRef.current.open) {
584
+ syncView({ open: false })
585
+ setOpen(false)
586
+ return
587
+ }
588
+ openPopup()
589
+ }
590
+ const toggleRef = React.useRef(() => {})
591
+ toggleRef.current = toggleFromButton
592
+
593
+ // 输入框工具排按钮(设置开关):挂载后扫描官方工具排注入;官方工具排随布局
594
+ // 状态被 React 重建时经观察器补注,开关值异步到达(无 DOM 变更)经重扫对账。
595
+ // 启停读 ref(开关值挂载拉取后即时反映),停用/卸载按标记全量移除
596
+ const buttonScanRef = React.useRef(null)
597
+ useEffect(() => {
598
+ function removeAll() {
599
+ document.querySelectorAll('[' + HISTORY_BTN_FLAG + ']').forEach((button) => button.remove())
600
+ }
601
+ function sync() {
602
+ if (enabledRef.current === false || buttonEnabledRef.current === false) {
603
+ removeAll()
604
+ return
605
+ }
606
+ const row = findToolsRow(document)
607
+ if (row) ensureHistoryButton(document, row, () => toggleRef.current())
608
+ }
609
+ buttonScanRef.current = sync
610
+ const observer = new MutationObserver(sync)
611
+ observer.observe(document.documentElement, { childList: true, subtree: true })
612
+ sync()
613
+ return () => {
614
+ buttonScanRef.current = null
615
+ observer.disconnect()
616
+ removeAll()
617
+ }
618
+ }, [])
619
+ // 开关状态/浮层启停变化即时重扫(观察器只覆盖 DOM 变更,状态变化需主动对账)
620
+ useEffect(() => {
621
+ if (buttonScanRef.current) buttonScanRef.current()
622
+ }, [historyEnabled, buttonEnabled])
623
+
558
624
  // 零高度锚点:平时不占任何界面空间,仅浮层打开时渲染;设置停用整体不渲染
559
625
  if (session === undefined || inputActions === undefined || historyEnabled === false) return null
560
626
  const promptsIdx = HISTORY_SCOPES.indexOf('prompts')
@@ -751,8 +817,6 @@ function forkSvg() {
751
817
  // 注入按钮标记:识别自家节点与官方按钮,防止重复注入与误删官方节点
752
818
  const STEER_BTN_FLAG = 'data-cx-steer-recall'
753
819
  const FORK_BTN_FLAG = 'data-cx-fork'
754
- // 家族版本环标记:识别自家 ‹n/m› 计数器节点,防重复注入
755
- const FAMILY_RING_FLAG = 'data-cx-family-ring'
756
820
  // 官方 pending steering 气泡的语义标记(官方同构:UserStyleBubble data 属性)与
757
821
  // 其操作图标排的 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定);
758
822
  // 消息节点的轮号标记(fork 锚点输入)
@@ -768,6 +832,9 @@ const FORK_PAGE_MAX_MESSAGES = 2000
768
832
  // 重试草稿通道:分叉成功后子会话尚未挂载,原输入先按子会话 id 暂存,
769
833
  // 子会话的 dock 挂载(inputActions 就绪)时消费回填并清除
770
834
  const pendingForkDrafts = new Map()
835
+ // 已消费会话:effect 依赖(inputActions 身份)变化会重跑,重跑时草稿已被 delete,
836
+ // 若不做标记会落入「无草稿」路径并对输入框产生二次写入(空写即清空)
837
+ const consumedForkDrafts = new Set()
771
838
  // 草稿消费重查:覆盖 open 切换期间「挂载先于登记」的窗口(单次即可,上限留裕量)
772
839
  const FORK_DRAFT_POLL_MS = 400
773
840
  const FORK_DRAFT_POLLS = 3
@@ -883,7 +950,7 @@ function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
883
950
  // 轮号无映射 = 该轮未完成,首问无文本(纯图等)= 无法重试,均不注入;
884
951
  // 首轮无前锚(宿主 fork 边界必须落在 turn/end 上,复制零事件不可表达),也不注入。
885
952
  // 分叉动作经宿主 sessions 服务面 fork+open(与官方 chat 同构),成功 toast 并切换
886
- function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSession, openSession, loadFamily, submitPrompt, loadTurnEnds }) {
953
+ function ForkDockWithBootstrap({ session, inputActions, useInput, forkSession, cancelSession, submitPrompt, loadTurnEnds }) {
887
954
  // 依赖键 = 会话 id:session 快照身份随每次投影更新漂移,不能作 effect 依赖;
888
955
  // loadTurnEnds 闭包身份同样不稳定,经 ref 取用
889
956
  const sessionId = session && session.sessionId
@@ -893,15 +960,39 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
893
960
  const submitRef = useRef(null)
894
961
  submitRef.current = submitPrompt
895
962
  const [turnEnds, setTurnEnds] = useState(null)
963
+ // 映射基线:重拉结果与基线比对,相同则不 setState——setState 会触发重渲染并再次
964
+ // 触发扫描,新轮在映射中缺失时形成 拉取→setState→扫描→拉取 的无限循环
965
+ const turnEndsRef = useRef(null)
966
+ // 在途门控:MutationObserver 在流式输出期间高频触发,无门控会造成请求风暴
967
+ const refreshPendingRef = useRef(false)
968
+ // 重拉入口装配到 ref 供扫描调用:新轮 turn/end 落在拉取窗口之后时映射里没有该轮,
969
+ // 注入资格判空即跳过,按钮永不出现(实测:必须刷新页面才恢复)。门控保证同时在途
970
+ // 最多一次;结果与基线相同则不 setState,避免 setState→扫描→拉取的循环
971
+ const refreshRef = useRef(null)
972
+ refreshRef.current = () => {
973
+ if (refreshPendingRef.current) return
974
+ refreshPendingRef.current = true
975
+ Promise.resolve(loadRef.current())
976
+ .then((map) => {
977
+ refreshPendingRef.current = false
978
+ if (turnEndsRef.current === map) return
979
+ turnEndsRef.current = map
980
+ setTurnEnds(map)
981
+ })
982
+ .catch(() => { refreshPendingRef.current = false })
983
+ }
896
984
  useEffect(() => {
897
985
  if (sessionId === undefined) return undefined
898
986
  let disposed = false
899
987
  // 拉取前置空:会话切换后旧会话的映射不得继续服务新会话——轮号跨会话重叠,
900
988
  // 旧 seq 会让分叉锚点错位;置空期间 ForkDock 以 null 短路,不注入按钮
901
989
  setTurnEnds(null)
990
+ turnEndsRef.current = null
902
991
  loadRef.current()
903
992
  .then((map) => {
904
- if (!disposed) setTurnEnds(map)
993
+ if (disposed) return
994
+ turnEndsRef.current = map
995
+ setTurnEnds(map)
905
996
  })
906
997
  .catch((error) => {
907
998
  // 映射拉取失败(网络/宿主繁忙):fork 无锚点可用,保持禁用不注入;
@@ -917,8 +1008,14 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
917
1008
  // 自动重发(开关开):草稿带 autoSubmit 标记,setDraft 后经 remote.session.prompt
918
1009
  // 立即提交原文(重生成语义);prompt 失败仅 toast——文本已在输入框,可手动发送
919
1010
  const [draftPollTick, setDraftPollTick] = useState(0)
1011
+ // 回填前探测输入框当前草稿:宿主输入快照(useInput)只在快照面可用,
1012
+ // 缺失时按空草稿处理(旧宿主降级为原行为)
1013
+ const liveDraft = typeof useInput === 'function' ? useInput((state) => state.draft) : ''
1014
+ const liveDraftRef = useRef('')
1015
+ liveDraftRef.current = typeof liveDraft === 'string' ? liveDraft : ''
920
1016
  useEffect(() => {
921
1017
  if (sessionId === undefined || !inputActions || typeof inputActions.setDraft !== 'function') return undefined
1018
+ if (consumedForkDrafts.has(sessionId)) return undefined
922
1019
  const draft = pendingForkDrafts.get(sessionId)
923
1020
  if (draft === undefined) {
924
1021
  if (draftPollTick >= FORK_DRAFT_POLLS) return undefined
@@ -926,27 +1023,39 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
926
1023
  return () => clearTimeout(timer)
927
1024
  }
928
1025
  pendingForkDrafts.delete(sessionId)
1026
+ consumedForkDrafts.add(sessionId)
929
1027
  const text = draft && typeof draft === 'object' ? draft.text : draft
930
1028
  const autoSubmit = Boolean(draft && typeof draft === 'object' && draft.autoSubmit === true)
931
- inputActions.setDraft(text)
1029
+ // 用户已开始输入:回填是破坏性整体替换(setDraft 清根重建),优先级低于用户输入;
1030
+ // 放弃回填并提示,避免吞掉用户在 dock 挂载窗口内的击键
1031
+ const draftOccupied = liveDraftRef.current.trim() !== ''
932
1032
  if (autoSubmit && submitRef.current) {
1033
+ // 提交即无草稿(官方发送语义):原文已发出,输入框不得残留,否则用户再发即重复消息;
1034
+ // 提交失败才回填文本,供手动重发
1035
+ if (draftOccupied) {
1036
+ toast('子会话已有输入,原输入未自动发送')
1037
+ return undefined
1038
+ }
933
1039
  Promise.resolve(submitRef.current({ sessionId, text })).catch((error) => {
934
- toast('自动重发失败,文本已在输入框可手动发送: ' + String(error && error.message || error), { kind: 'error' })
1040
+ if (typeof inputActions.setDraft === 'function') inputActions.setDraft(text)
1041
+ toast('自动重发失败,文本已回填输入框可手动发送: ' + String(error && error.message || error), { kind: 'error' })
935
1042
  })
1043
+ } else if (draftOccupied) {
1044
+ toast('子会话已有输入,原输入未回填')
1045
+ } else {
1046
+ inputActions.setDraft(text)
936
1047
  }
937
1048
  return undefined
938
1049
  }, [sessionId, inputActions, draftPollTick])
939
- return h(ForkDock, { session, forkSession, cancelSession, openSession, loadFamily, turnEnds })
1050
+ return h(ForkDock, { session, forkSession, cancelSession, turnEnds, refreshRef })
940
1051
  }
941
1052
 
942
- function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily, turnEnds }) {
1053
+ function ForkDock({ session, forkSession, cancelSession, turnEnds, refreshRef }) {
943
1054
  const [enabled, setEnabled] = useState(true)
944
1055
  const [autoResend, setAutoResend] = useState(false)
945
1056
  const turnEndsRef = useRef(null)
946
1057
  const forkRef = useRef(null)
947
1058
  const cancelRef = useRef(null)
948
- const familyRef = useRef(null)
949
- const openRef = useRef(null)
950
1059
  const sessionRef = useRef(null)
951
1060
  const enabledRef = useRef(true)
952
1061
  const autoResendRef = useRef(false)
@@ -965,13 +1074,10 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
965
1074
  turnEndsRef.current = turnEnds
966
1075
  forkRef.current = forkSession
967
1076
  cancelRef.current = cancelSession
968
- familyRef.current = loadFamily
969
- openRef.current = openSession
970
1077
  sessionRef.current = session
971
1078
  useEffect(() => {
972
1079
  function removeAll() {
973
1080
  document.querySelectorAll('[' + FORK_BTN_FLAG + ']').forEach((button) => button.remove())
974
- document.querySelectorAll('[' + FAMILY_RING_FLAG + ']').forEach((node) => node.remove())
975
1081
  }
976
1082
  function scan() {
977
1083
  // 停用即不注入并移除已注入按钮(开关可在挂载后才到达停用值)
@@ -993,13 +1099,18 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
993
1099
  if (rawTurn === null || rawTurn.trim() === '') return
994
1100
  const turn = Number(rawTurn)
995
1101
  if (!Number.isInteger(turn)) return
996
- // 重试资格三查:映射缺失的轮(超窗口)不可分叉;首轮无前锚(宿主
1102
+ // 重试资格三查:映射缺失的轮(超窗口/新轮)不可分叉;首轮无前锚(宿主
997
1103
  // fork 边界必须落在 turn/end,复制零事件不可表达);无首问文本(纯图等)
998
1104
  // 回填无从谈起。open=true 的进行中轮 turn/end 未落账,同样可分叉——
999
1105
  // 锚点取其前一个闭合轮,该轮未完成的回复分叉后停掉(见点击处理器)。
1000
1106
  // 按钮在场即暗示可用,禁用态文案无法区分成因,不注入即误导
1001
1107
  const entry = turnEndsRef.current.get(turn)
1002
- if (entry === undefined || entry.text === null) return
1108
+ if (entry === undefined) {
1109
+ // 新轮:映射是挂载时的一次快照,补拉后由 setState 触发重扫
1110
+ if (refreshRef.current) refreshRef.current()
1111
+ return
1112
+ }
1113
+ if (entry.text === null) return
1003
1114
  let previous = null
1004
1115
  for (const key of turnEndsRef.current.keys()) {
1005
1116
  const candidate = turnEndsRef.current.get(key)
@@ -1032,9 +1143,10 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
1032
1143
  .then((childId) => {
1033
1144
  // 暂存先于 toast:open 的子会话挂载可能紧随 resolve,回填承诺必须先就位;
1034
1145
  // open 失败时暂存保留,用户手动打开子会话仍兑现;
1035
- // 自动重发开关开:登记带标记草稿,子会话消费时回填后经 prompt 提交
1146
+ // 自动重发开关开:登记带标记草稿,子会话消费时直接提交(不留草稿)
1036
1147
  if (typeof childId === 'string' && childId !== '') {
1037
1148
  pendingForkDrafts.set(childId, { text: currentEntry.text, autoSubmit: autoResendOn })
1149
+ consumedForkDrafts.delete(childId)
1038
1150
  }
1039
1151
  // 进行中轮分叉后原会话该轮无人再读,停掉止损;失败不影响分叉成功事实
1040
1152
  if (currentEntry.open === true && typeof cancelSessionFn === 'function') {
@@ -1042,7 +1154,7 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
1042
1154
  console.warn('[context-manager] 分叉后停止原会话进行中回复失败', error)
1043
1155
  })
1044
1156
  }
1045
- toast('已分叉,原输入已填入子会话输入框')
1157
+ toast(autoResendOn ? '已分叉,原输入已自动发送到子会话' : '已分叉,原输入已填入子会话输入框')
1046
1158
  return childId
1047
1159
  })
1048
1160
  .catch((error) => {
@@ -1053,73 +1165,6 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
1053
1165
  button.appendChild(forkSvg())
1054
1166
  actionsRow.appendChild(button)
1055
1167
  })
1056
- // 家族版本环:每个用户气泡操作排的 ‹n/m›,箭头在家族成员间跳转;
1057
- // 单成员家族/快照面缺失不注入(与官方按钮并存,hover 显隐一致)
1058
- const loadFamilyFn = familyRef.current
1059
- const ring = (sessionRef.current && typeof loadFamilyFn === 'function')
1060
- ? loadFamilyFn(sessionRef.current.sessionId)
1061
- : null
1062
- if (ring && ring.total >= 2) {
1063
- bubbles.forEach((bubble) => {
1064
- if (bubble.getAttribute(FLOW_KIND_ATTR) !== 'user') return
1065
- const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
1066
- if (!actionsRow || actionsRow.querySelector('[' + FAMILY_RING_FLAG + ']')) return
1067
- const official = actionsRow.querySelector('button:not([' + FORK_BTN_FLAG + ']):not([' + STEER_BTN_FLAG + '])')
1068
- if (!official) return
1069
- const ringEl = document.createElement('span')
1070
- ringEl.setAttribute(FAMILY_RING_FLAG, '')
1071
- ringEl.className = 'cx-family-ring'
1072
- ringEl.title = '家族版本:本会话在同源分叉家族中的序位,箭头在各版本间切换'
1073
- const makeChevron = (direction, tip) => {
1074
- const chevron = document.createElement('button')
1075
- chevron.type = 'button'
1076
- chevron.className = official.className
1077
- chevron.setAttribute(FAMILY_RING_FLAG, '')
1078
- chevron.title = tip
1079
- chevron.setAttribute('aria-label', tip)
1080
- chevron.addEventListener('click', () => jumpFamilyMember(direction))
1081
- chevron.appendChild(familyChevronSvg(direction))
1082
- return chevron
1083
- }
1084
- const label = document.createElement('span')
1085
- label.setAttribute(FAMILY_RING_FLAG, '')
1086
- label.className = 'cx-family-ring__label'
1087
- label.textContent = ring.index + '/' + ring.total
1088
- ringEl.appendChild(makeChevron(-1, '上一个家族版本'))
1089
- ringEl.appendChild(label)
1090
- ringEl.appendChild(makeChevron(1, '下一个家族版本'))
1091
- actionsRow.appendChild(ringEl)
1092
- })
1093
- }
1094
- }
1095
- // 家族环 chevron 图标:与官方 outline chevron 同款线形(12px 视窗)
1096
- function familyChevronSvg(direction) {
1097
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1098
- svg.setAttribute('width', '12')
1099
- svg.setAttribute('height', '12')
1100
- svg.setAttribute('viewBox', '0 0 14 14')
1101
- svg.setAttribute('fill', 'none')
1102
- svg.setAttribute('aria-hidden', 'true')
1103
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1104
- path.setAttribute('d', direction < 0 ? 'M8.75 3.5L5.25 7L8.75 10.5' : 'M5.25 3.5L8.75 7L5.25 10.5')
1105
- path.setAttribute('stroke', 'currentColor')
1106
- path.setAttribute('stroke-width', '1.2')
1107
- path.setAttribute('stroke-linecap', 'round')
1108
- path.setAttribute('stroke-linejoin', 'round')
1109
- svg.appendChild(path)
1110
- return svg
1111
- }
1112
- function jumpFamilyMember(offset) {
1113
- const loadFamilyFn = familyRef.current
1114
- const openFn = openRef.current
1115
- const current = sessionRef.current
1116
- if (!current || typeof loadFamilyFn !== 'function' || typeof openFn !== 'function') return
1117
- const ring = loadFamilyFn(current.sessionId)
1118
- if (!ring) return
1119
- const nextIndex = ring.index - 1 + offset
1120
- if (nextIndex < 0 || nextIndex >= ring.members.length) return
1121
- const target = ring.members[nextIndex]
1122
- if (target && target !== current.sessionId) openFn(target)
1123
1168
  }
1124
1169
  if (typeof MutationObserver === 'undefined' || typeof document.querySelectorAll !== 'function') return undefined
1125
1170
  const scanRef = { current: scan }
@@ -1140,6 +1185,7 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
1140
1185
  // 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
1141
1186
  // 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
1142
1187
  const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,浏览并回填历史输入;浮层内 ←/→ 切换范围(常用 / 当前会话 / 本工作区 / 全部工作区),顶部搜索框过滤条目,行悬停星标可收藏常用提示词。停用后快捷键与浮层整体关闭,刷新页面生效。'
1188
+ const HISTORY_BUTTON_SWITCH_TITLE = '输入框下方工具排显示历史输入按钮,点击打开浮层,再点关闭,与 Alt+↑ 等效;停用仅隐藏按钮,快捷键与浮层不受影响;刷新页面生效。'
1143
1189
  const STEER_SWITCH_TITLE = '插话发送后、尚未被智能体应用期间,在该插话气泡的操作图标排显示撤回按钮,点击撤回并把原文填回输入框(覆盖输入框现有草稿);含附件的插话不可撤回;消息被应用后按钮随气泡消失,恰在应用瞬间点击会提示已应用且不动草稿。停用即不再注入,刷新页面生效。'
1144
1190
  const FORK_SWITCH_TITLE = '消息气泡操作排显示分叉按钮,点击分叉出新会话到该轮之前(该轮不带入子会话),该轮的用户输入自动回填子会话输入框供编辑重发,子会话自动打开且标题尾号递增;进行中的轮(回复尚未完成)同样可分叉,分叉后自动停止本会话该轮未完成的回复;首轮(无更早上下文可继承,新建会话即为同义操作)与无文本输入的轮(纯图等,无从重发)不注入;停用即不再注入,刷新页面生效。'
1145
1191
  const FORK_AUTO_RESEND_SWITCH_TITLE = '分叉成功后自动把该轮原输入发送到子会话立即开跑(重生成语义,相当于原输入重跑一遍);关闭时分叉仅把原输入回填子会话输入框,由你编辑后再手动发送。'
@@ -1234,6 +1280,7 @@ function switchRow(url, label, title, okText) {
1234
1280
  }
1235
1281
 
1236
1282
  const HistorySwitchRow = switchRow(HISTORY_ENABLED_URL, '历史输入浮层(Alt+↑)', HISTORY_SWITCH_TITLE, '历史输入浮层')
1283
+ const HistoryButtonSwitchRow = switchRow(HISTORY_BUTTON_ENABLED_URL, '输入框历史按钮', HISTORY_BUTTON_SWITCH_TITLE, '输入框历史按钮')
1237
1284
  const SteerSwitchRow = switchRow(STEER_ENABLED_URL, '插话撤回', STEER_SWITCH_TITLE, '插话撤回')
1238
1285
  const ForkSwitchRow = switchRow(FORK_ENABLED_URL, '对话 fork', FORK_SWITCH_TITLE, '对话 fork')
1239
1286
  const ForkAutoResendSwitchRow = switchRow(FORK_AUTO_RESEND_URL, '分叉后自动重发', FORK_AUTO_RESEND_SWITCH_TITLE, '分叉后自动重发')
@@ -1258,6 +1305,7 @@ function ContextPanel() {
1258
1305
  open && h('div', { className: 'cx-card__body' },
1259
1306
  h('span', { className: 'cx-panel__hint' }, '变更刷新页面生效。'),
1260
1307
  h(HistorySwitchRow),
1308
+ h(HistoryButtonSwitchRow),
1261
1309
  h(SteerSwitchRow),
1262
1310
  h(ForkSwitchRow),
1263
1311
  h(ForkAutoResendSwitchRow),
@@ -1290,18 +1338,6 @@ function ContextPanel() {
1290
1338
  })
1291
1339
  : null
1292
1340
 
1293
- // 家族环通道:会话列表快照(parentSessionId 字段)投影为当前会话的 ‹n/m› 计数;
1294
- // 快照面缺失(旧宿主)时返回 null,计数器整体不注入
1295
- const loadFamily = (sessionId) => {
1296
- if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return null
1297
- const snapshot = sessions.list.getSnapshot()
1298
- const rows = snapshot && snapshot.byId
1299
- ? (Array.isArray(snapshot.ids) ? snapshot.ids : []).map((id) => snapshot.byId[id]).filter(Boolean)
1300
- : (Array.isArray(snapshot) ? snapshot : [])
1301
- if (rows.length === 0) return null
1302
- return familyRing(sessionFamilyMap(rows), sessionId)
1303
- }
1304
-
1305
1341
  // 自动重发通道:开启开关时分叉后以该轮原输入 prompt 子会话;
1306
1342
  // remote.session 缺失或缺 prompt(旧宿主/部分 stub)时返回 null,降级为仅回填;
1307
1343
  // 请求形态与官方 client face 同源:content 为 text 分段数组(宿主 hasPromptContent
@@ -1387,8 +1423,6 @@ function ContextPanel() {
1387
1423
  cancelSession: (typeof remoteSession.cancel === 'function')
1388
1424
  ? (opts) => remoteSession.cancel(opts)
1389
1425
  : null,
1390
- openSession: (sessions && typeof sessions.open === 'function') ? (id) => sessions.open(id) : null,
1391
- loadFamily: () => loadFamily(sessionId),
1392
1426
  submitPrompt,
1393
1427
  loadTurnEnds: () => followOpening(remoteSession, sessionId).then((records) => {
1394
1428
  // 轮号 → { 结束 seq, 该轮首问文本, open }:上个 turn/end 之后首条