dsh-retrace 0.4.16 → 0.4.18
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/lib/adapter/contract.js +48 -0
- package/lib/adapter/dsh-writer.js +230 -0
- package/lib/adapter/dsh.js +118 -0
- package/lib/client.bundle.js +63 -4
- package/lib/client.js +61 -1
- package/lib/dynamic-client.js +63 -4
- package/lib/dynamic-host.js +326 -148
- package/lib/host-core.js +89 -146
- package/lib/http.js +13 -0
- package/lib/index.js +162 -6
- package/lib/message-list.js +112 -0
- package/lib/prewrite-guard.js +16 -3
- package/lib/rollback.js +9 -3
- package/lib/session-adapter.js +83 -0
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/adapter/contract.js
|
|
3
|
+
*
|
|
4
|
+
* 适配器层契约(2026-09-01)——业务层与平台解耦的接口定义。
|
|
5
|
+
*
|
|
6
|
+
* 目标:换架构(不用 DSH)时,业务层(message-list.js/守卫)零改动,
|
|
7
|
+
* 只需实现新的「读事件 + 写替换」两个适配器。
|
|
8
|
+
*
|
|
9
|
+
* 三个角色:
|
|
10
|
+
* - 业务层:message-list.js(纯函数,零依赖)——消费「通用事件」,产出消息列表/遮蔽;
|
|
11
|
+
* - 适配器层(本文件):定义「事件读取器」和「替换写入器」两个接口;
|
|
12
|
+
* - 平台实现:dsh-adapter.js(DSH 实现)、未来 self-runtime-adapter.js 等。
|
|
13
|
+
*
|
|
14
|
+
* 通用事件格式(业务层消费的最小形态):
|
|
15
|
+
* { seq, type, turn, data, source }——与具体平台的存储格式无关。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 事件读取器接口:按 sessionId 提供「全量事件」(可靠事实,非内存视图)。
|
|
20
|
+
* @typedef {Object} EventReader
|
|
21
|
+
* @property {(sessionId: string) => Promise<Array<{seq:number, type:string, turn?:number, data?:object, source?:object}>>} readEvents
|
|
22
|
+
* - 返回按 seq 升序的全量事件;
|
|
23
|
+
* - 必须从持久化层读(文件/存储),不依赖运行内存(host 内存可能稀疏/窗口化);
|
|
24
|
+
* - 失败返回 null/throw 由调用方 fallback。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 替换写入器接口:在会话上写「遮蔽替换」(模型侧消费)。
|
|
29
|
+
* @typedef {Object} ReplaceWriter
|
|
30
|
+
* @property {(sessionId: string, span: {start:number, end:number, shadowedSeqs:number[]}) => Promise<object>} writeReplace
|
|
31
|
+
* - 用平台机制写替换标记(DSH = session.append + surfaceOp replace);
|
|
32
|
+
* - 返回写入结果(marker seq 等)。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 组装一个「完整适配器」:读事件 + 写替换,业务层只依赖它。
|
|
37
|
+
* @param {EventReader} reader
|
|
38
|
+
* @param {ReplaceWriter} writer
|
|
39
|
+
*/
|
|
40
|
+
export function createAdapter(reader, writer) {
|
|
41
|
+
return { reader, writer }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 空适配器(无平台时,业务层可独立运行)。 */
|
|
45
|
+
export const NULL_ADAPTER = createAdapter(
|
|
46
|
+
{ readEvents: async () => null },
|
|
47
|
+
{ writeReplace: async () => null },
|
|
48
|
+
)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/adapter/dsh-writer.js
|
|
3
|
+
*
|
|
4
|
+
* DSH 平台的「遮蔽写入器」(ReplaceWriter 的 DSH 实现)——把业务意图
|
|
5
|
+
* 「遮蔽这些消息」翻译成 DSH 事件形状。
|
|
6
|
+
*
|
|
7
|
+
* 抽象设计(工程-生产级运行时/编辑撤销分支跳过-消息列表投影抽象-设计-20260902.md):
|
|
8
|
+
* 编辑/撤销/分支/跳过 = 通用消息列表投影操作,业务层只表达「遮蔽 roundRange」。
|
|
9
|
+
* **DSH 翻译成本全部隔离在本文件**:
|
|
10
|
+
* - 官方 token-meter 要求 assistant/message 必须有打开的 step(T1)→
|
|
11
|
+
* marker 必须落在合法 turn/step 位置(三情形):
|
|
12
|
+
* ① 有打开的 step(回合中)→ 携带该 step 的 turn/step;
|
|
13
|
+
* ② 无打开 step 但有打开着的 turn(回合内 step 间隙,1e99e1ff 现场)
|
|
14
|
+
* → 该 turn 号 + 新 step 号(文件全量 max + 1,绕开窗口化内存);
|
|
15
|
+
* ③ 无打开 turn(真轮次间)→ 完整 turn 信封 + 推进 agent-loop lastTurn
|
|
16
|
+
* (防重发/下一条消息复用同一 turn 号 = duplicate start);
|
|
17
|
+
* - 客户端渲染层(复盘 2026-09-02):任何 step/marker 不得写 turn:null
|
|
18
|
+
* (D8 白屏死循环)、同 turn 内 step 不得复用(step key 冲突白屏)——
|
|
19
|
+
* T3/T4 检测规则见 dsh-log-contract。
|
|
20
|
+
*
|
|
21
|
+
* host-core 零 import(动态插件 realm 可运行),本文件被 index.js 引用,
|
|
22
|
+
* 依赖方向 adapter → host-core(安全);host-core 通过 hooks.writeMarker
|
|
23
|
+
* 注入本 writer,不反向 import。
|
|
24
|
+
*/
|
|
25
|
+
import { editorError, editorId, lastModelSource } from '../host-core.js'
|
|
26
|
+
// 零 import 链(host-core 零 import):本文件可被 generate-dynamic.mjs inline 进
|
|
27
|
+
// dynamic-host(动态插件 realm 不能 import)。文件全量的 step 号(readMaxStep)
|
|
28
|
+
// 由装配者注入;本文件自带内存 maxStepInTurn 兜底。
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 创建 DSH 遮蔽写入器。
|
|
32
|
+
* @param {object} deps
|
|
33
|
+
* @param {object} deps.agents — agent 注册表(agents.get,情形③计数器推进)。
|
|
34
|
+
* @param {Function} [deps.validateMarker] — 写前校验钩子(prewrite-guard)。
|
|
35
|
+
* @param {(line: string) => void} [deps.log]
|
|
36
|
+
*/
|
|
37
|
+
export function createDshMarkerWriter({ agents, validateMarker, readMaxStep, log = () => {} } = {}) {
|
|
38
|
+
return {
|
|
39
|
+
/**
|
|
40
|
+
* 写入一个「遮蔽 marker」:业务意图(遮蔽 span)翻译成 DSH 事件形状。
|
|
41
|
+
* @param {object} session - DSH Session 实例。
|
|
42
|
+
* @param {{start:number, end:number, shadowedSeqs:number[]}} span - 遮蔽范围(业务层算好)。
|
|
43
|
+
* @param {{op:string, targetSeq:number, originalText:string}} meta - 业务元数据。
|
|
44
|
+
* @returns {Promise<object>} markerEvent(调用方读取 seq/editor 等)。
|
|
45
|
+
*/
|
|
46
|
+
async writeMarker(session, span, meta) {
|
|
47
|
+
const op = String(meta?.op ?? '')
|
|
48
|
+
const targetSeq = Number(meta?.targetSeq)
|
|
49
|
+
const model = lastModelSource(session)
|
|
50
|
+
if (!model) {
|
|
51
|
+
throw editorError(
|
|
52
|
+
'no-model-header',
|
|
53
|
+
'This session has no model header yet; send at least one message before recalling or editing.',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
const marker = {
|
|
57
|
+
id: editorId(op),
|
|
58
|
+
role: 'assistant',
|
|
59
|
+
content: [],
|
|
60
|
+
source: { kind: 'model', provider: model.provider, model: model.model },
|
|
61
|
+
}
|
|
62
|
+
// ── 三情形 turn/step 赋值(DSH 翻译核心,见文件头注释)──
|
|
63
|
+
const openStep = findOpenStep(session)
|
|
64
|
+
const openTurn = openStep === null ? findOpenTurn(session) : null
|
|
65
|
+
let markerTurn
|
|
66
|
+
let markerStep
|
|
67
|
+
let wrappedBefore = [] // marker 前的包裹事件(turn/start、step/start)
|
|
68
|
+
let wrappedAfter = [] // marker 后的包裹事件(step/end、turn/end)
|
|
69
|
+
if (openStep) {
|
|
70
|
+
markerTurn = openStep.turn
|
|
71
|
+
markerStep = openStep.step
|
|
72
|
+
} else if (openTurn) {
|
|
73
|
+
markerTurn = openTurn.turn
|
|
74
|
+
// 情形② step 号 = **max(内存, 文件) + 1**(独立审查 2026-09-02 处置):
|
|
75
|
+
// - 内存 maxStepInTurn 覆盖本进程刚写的 marker(文件 flush 滞后时文件
|
|
76
|
+
// 读不到——同一打开 turn 内连续两次情形② 若只信文件会取到同一 step
|
|
77
|
+
// → step key 冲突白屏);
|
|
78
|
+
// - 文件全量 readMaxStep 覆盖窗口外既有 step(host 窗口化内存不可信,
|
|
79
|
+
// 算小会与窗口外冲突,复盘 2026-09-02 §五);
|
|
80
|
+
// 两者取大 = 双向覆盖。readMaxStep 由装配者注入(index.js 从文件全量算),
|
|
81
|
+
// 动态路径(dynamic-host inline)未注入时仅内存覆盖(窗口外风险已文档化)。
|
|
82
|
+
let maxStep = maxStepInTurn(session, openTurn.turn)
|
|
83
|
+
if (typeof readMaxStep === 'function') {
|
|
84
|
+
const fromFile = await readMaxStep(session.id, openTurn.turn)
|
|
85
|
+
if (typeof fromFile === 'number' && Number.isSafeInteger(fromFile) && fromFile > maxStep) maxStep = fromFile
|
|
86
|
+
}
|
|
87
|
+
markerStep = maxStep + 1
|
|
88
|
+
wrappedBefore = [{ type: 'step/start', data: { turn: markerTurn, step: markerStep } }]
|
|
89
|
+
wrappedAfter = [{ type: 'step/end', data: { turn: markerTurn, step: markerStep } }]
|
|
90
|
+
} else {
|
|
91
|
+
markerTurn = nextTurnOf(session)
|
|
92
|
+
markerStep = 1
|
|
93
|
+
wrappedBefore = [
|
|
94
|
+
{ type: 'turn/start', data: { turn: markerTurn } },
|
|
95
|
+
{ type: 'step/start', data: { turn: markerTurn, step: 1 } },
|
|
96
|
+
]
|
|
97
|
+
wrappedAfter = [
|
|
98
|
+
{ type: 'step/end', data: { turn: markerTurn, step: 1 } },
|
|
99
|
+
// 官方契约(逐字镜像 dsh-agent-loop/lib/index.js:620):turn/end 必须带
|
|
100
|
+
// reason.kind(completed|max-tokens|blocked|aborted|error|interrupted)——
|
|
101
|
+
// 缺失 = malformed → 官方 validation 拒绝 → 会话加载失败(1f4d986e 事故,
|
|
102
|
+
// 修复线 check-turn-end-reason.mjs 固化)。我们的信封 turn 立即完整关闭 →
|
|
103
|
+
// kind: 'completed'(与 agent-loop 正常完成一致)。
|
|
104
|
+
{ type: 'turn/end', data: { turn: markerTurn, reason: { kind: 'completed' } } },
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
const data = {
|
|
108
|
+
turn: markerTurn,
|
|
109
|
+
step: markerStep,
|
|
110
|
+
message: marker,
|
|
111
|
+
editor: {
|
|
112
|
+
targetSeq,
|
|
113
|
+
text: typeof meta?.originalText === 'string' ? meta.originalText.slice(0, 2000) : '',
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
const surfaceOp = { op: 'replace', start: span.start, end: span.end }
|
|
117
|
+
const sourceEventSeqs = Array.isArray(span.shadowedSeqs) ? span.shadowedSeqs.slice() : []
|
|
118
|
+
// 2026-09-01:把遮蔽 seq 冗余进 data.shadowedSeqs——部分客户端事件管道会剥离
|
|
119
|
+
// event 顶层 sourceEventSeqs(0.4.12 实测:撤回/编辑后消息不隐藏 = client 拿不到
|
|
120
|
+
// shadowedSeqs)。data 里的字段随事件体持久化,client 读 data 更稳(fallback 顶层)。
|
|
121
|
+
data.shadowedSeqs = sourceEventSeqs
|
|
122
|
+
// 包裹事件**先于校验构建**(校验与落盘共用同一组事件),校验钩子拿到完整序列
|
|
123
|
+
// (wrappedBefore + envelope + wrappedAfter)做 T1 自检——消除"自检只见裸信封 →
|
|
124
|
+
// 恒报 markerT1Broken"的误报(0.4.12-0.4.16 每次轮次间编辑刷 31 行日志的根因)。
|
|
125
|
+
if (typeof validateMarker === 'function') {
|
|
126
|
+
const result = await validateMarker(
|
|
127
|
+
session,
|
|
128
|
+
{ type: 'assistant/message', data, surfaceOp, sourceEventSeqs },
|
|
129
|
+
{ wrappedBefore, wrappedAfter },
|
|
130
|
+
)
|
|
131
|
+
if (result && result.t1Ok === false) {
|
|
132
|
+
// 理论上三情形信封后 T1 恒通过;残留 fallback 标注(防御)。
|
|
133
|
+
data.editor.markerT1Broken = true
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const w of wrappedBefore) session.append(w.type, w.data)
|
|
137
|
+
const markerEvent = session.append('assistant/message', data, { surfaceOp, sourceEventSeqs })
|
|
138
|
+
for (const w of wrappedAfter) session.append(w.type, w.data)
|
|
139
|
+
// 情形③:我们消费了 nextTurn(信封里的 turn/start),把 agent-loop 的 lastTurn
|
|
140
|
+
// 推进到该 turn——重发/下一条消息才落到 nextTurn+1,不会复用(防 duplicate start)。
|
|
141
|
+
if (wrappedBefore.length >= 2) {
|
|
142
|
+
const agent = agents?.get?.(session.id)
|
|
143
|
+
advanceLoopTurn(agent, markerTurn, log)
|
|
144
|
+
}
|
|
145
|
+
return markerEvent
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 找当前打开的 step(最近一次未闭合的 step/start 的 turn/step)。
|
|
152
|
+
* 扫描 session.events:step/start 开、step/end 关;末尾仍开即返回。
|
|
153
|
+
* 无打开 step 返回 null(轮次间编辑)。
|
|
154
|
+
*/
|
|
155
|
+
export function findOpenStep(session) {
|
|
156
|
+
const events = Array.isArray(session?.events) ? session.events : []
|
|
157
|
+
let open = null
|
|
158
|
+
for (const event of events) {
|
|
159
|
+
if (event?.type === 'step/start') {
|
|
160
|
+
open = { turn: event.data?.turn, step: event.data?.step }
|
|
161
|
+
} else if (event?.type === 'step/end') {
|
|
162
|
+
open = null
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return open
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 找当前打开着的 turn(最近一次 turn/start 无配对 turn/end)。
|
|
170
|
+
* 扫描 session.events:turn/start 开、turn/end 关;末尾仍开即返回。
|
|
171
|
+
* 无打开 turn 返回 null(真轮次间,情形③)。
|
|
172
|
+
*/
|
|
173
|
+
export function findOpenTurn(session) {
|
|
174
|
+
const events = Array.isArray(session?.events) ? session.events : []
|
|
175
|
+
let open = null
|
|
176
|
+
for (const event of events) {
|
|
177
|
+
if (event?.type === 'turn/start') {
|
|
178
|
+
open = { turn: event.data?.turn }
|
|
179
|
+
} else if (event?.type === 'turn/end') {
|
|
180
|
+
open = null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return open
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 某 turn 内已出现的最大 step 号(无 step 返回 0)。内存扫描(窗口化不可信时用文件版)。 */
|
|
187
|
+
export function maxStepInTurn(session, turn) {
|
|
188
|
+
const events = Array.isArray(session?.events) ? session.events : []
|
|
189
|
+
let max = 0
|
|
190
|
+
for (const event of events) {
|
|
191
|
+
if (event?.data?.turn !== turn) continue
|
|
192
|
+
const step = event?.data?.step
|
|
193
|
+
if (typeof step === 'number' && Number.isSafeInteger(step) && step > max) max = step
|
|
194
|
+
}
|
|
195
|
+
return max
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 下一个 turn 号:scan 最大 turn(turn/start、step/start、assistant/message、user/message
|
|
200
|
+
* 的 data.turn 中取最大)+1。无任何 turn 时从 1 起。
|
|
201
|
+
*/
|
|
202
|
+
export function nextTurnOf(session) {
|
|
203
|
+
const events = Array.isArray(session?.events) ? session.events : []
|
|
204
|
+
let max = 0
|
|
205
|
+
for (const event of events) {
|
|
206
|
+
const t = event?.data?.turn
|
|
207
|
+
if (typeof t === 'number' && Number.isSafeInteger(t) && t > max) max = t
|
|
208
|
+
}
|
|
209
|
+
return max + 1
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 推进 agent-loop 的 turn 计数器(情形③专用)。
|
|
214
|
+
* 仅在 loop 处于 idle 且 lastTurn+1 === consumedTurn 时推进——守卫防误伤
|
|
215
|
+
* (loop 已推进/文件被外部改号时跳过;信封的 turn/start 已让文件 max turn 前移,
|
|
216
|
+
* 跨重启自愈,这里只补同一 loop 实例的内存计数器)。
|
|
217
|
+
*/
|
|
218
|
+
function advanceLoopTurn(agent, consumedTurn, log = () => {}) {
|
|
219
|
+
if (!agent || typeof agent !== 'object') return
|
|
220
|
+
const phase = agent.phase
|
|
221
|
+
if (!phase || phase.kind !== 'idle') {
|
|
222
|
+
log(`retrace: advanceLoopTurn skipped — agent not idle (${phase?.kind ?? 'no-phase'})`)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (!Number.isSafeInteger(phase.lastTurn) || phase.lastTurn + 1 !== consumedTurn) {
|
|
226
|
+
log(`retrace: advanceLoopTurn skipped — lastTurn ${phase?.lastTurn} +1 !== consumed ${consumedTurn}`)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
phase.lastTurn = consumedTurn
|
|
230
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/adapter/dsh.js
|
|
3
|
+
*
|
|
4
|
+
* DSH 平台适配器(2026-09-01)——实现 EventReader 接口。
|
|
5
|
+
*
|
|
6
|
+
* 职责:从 DSH 会话文件(session.jsonl.zstd)读全量事件——可靠事实,
|
|
7
|
+
* 不依赖 host 内存视图(DSH 2.0.3 host 的 session.events 可能稀疏/窗口化)。
|
|
8
|
+
*
|
|
9
|
+
* 换架构时:业务层(message-list.js/守卫)零改动,新平台实现自己的 EventReader
|
|
10
|
+
* (读自己的日志格式 → 同样的通用事件结构)。
|
|
11
|
+
*/
|
|
12
|
+
import { readdirSync, accessSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
|
|
16
|
+
/** 找 DSH 会话文件路径(遍历 ~/.dsh/sessions 各工作区)。 */
|
|
17
|
+
export function sessionFilePath(sessionId) {
|
|
18
|
+
const root = join(homedir(), '.dsh', 'sessions')
|
|
19
|
+
for (const workspace of readdirSync(root)) {
|
|
20
|
+
const candidate = join(root, workspace, String(sessionId), 'session.jsonl.zstd')
|
|
21
|
+
try { accessSync(candidate); return candidate } catch { /* keep looking */ }
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 是否是真实的用户输入(轮边界)——排除 context/steering 注入。 */
|
|
27
|
+
export function isRoundBoundary(event) {
|
|
28
|
+
return event?.type === 'user/message' && event?.data?.source?.kind === 'user'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* DSH 事件读取器:从文件读全量事件(通用事件结构)。
|
|
33
|
+
* @returns {Promise<Array<{seq:number, type:string, turn?:number, data?:object, source?:object}>|null>}
|
|
34
|
+
*/
|
|
35
|
+
async function readEvents(sessionId) {
|
|
36
|
+
const filePath = sessionFilePath(sessionId)
|
|
37
|
+
return readEventsFromFile(filePath)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 从指定文件路径读全量事件(测试可注入路径;生产走 readEvents 找 ~/.dsh)。 */
|
|
41
|
+
async function readEventsFromFile(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
if (!filePath) return null
|
|
44
|
+
const { loadSessionLog } = await import('dsh-log-contract')
|
|
45
|
+
const log = loadSessionLog(filePath)
|
|
46
|
+
return log.events.map((r) => r.event)
|
|
47
|
+
} catch { return null }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 从全量事件计算遮蔽范围(业务逻辑,基于通用事件,与 DSH 无关)。
|
|
52
|
+
* @param {Array} events - 全量事件(通用格式)。
|
|
53
|
+
* @param {number|string} target - seq 或 messageId。
|
|
54
|
+
* @param {'round'|'tail'} [mode] - round=遮蔽目标轮;tail=遮蔽目标之后(编辑 fromScratch)。
|
|
55
|
+
*/
|
|
56
|
+
export function computeSpan(events, target, mode = 'round') {
|
|
57
|
+
if (!Array.isArray(events)) return null
|
|
58
|
+
let seq = typeof target === 'number' ? target : -1
|
|
59
|
+
if (seq === -1) {
|
|
60
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
61
|
+
const ev = events[i]
|
|
62
|
+
const id = ev?.type === 'user/message' ? ev.data?.id : ev?.type === 'assistant/message' ? ev.data?.message?.id : undefined
|
|
63
|
+
if (typeof id === 'string' && id === target) { seq = ev.seq; break }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (seq === -1 || !events[seq]) return null
|
|
67
|
+
// surface 节点 = user/assistant/tool 消息(近似 surface 节点,与官方 foldSurface 一致)
|
|
68
|
+
const nodes = []
|
|
69
|
+
for (const ev of events) {
|
|
70
|
+
if (ev && (ev.type === 'user/message' || ev.type === 'assistant/message' || ev.type === 'tool/result')) nodes.push(ev.seq)
|
|
71
|
+
}
|
|
72
|
+
if (mode === 'tail') {
|
|
73
|
+
const shadowedSeqs = nodes.filter((s) => s >= seq)
|
|
74
|
+
if (shadowedSeqs.length === 0) return null
|
|
75
|
+
return { start: shadowedSeqs[0], end: shadowedSeqs[shadowedSeqs.length - 1], shadowedSeqs }
|
|
76
|
+
}
|
|
77
|
+
const index = nodes.indexOf(seq)
|
|
78
|
+
if (index === -1) return null
|
|
79
|
+
let startIdx = index
|
|
80
|
+
for (let i = index; i >= 0; i--) { if (isRoundBoundary(events[nodes[i]])) { startIdx = i; break } }
|
|
81
|
+
let endIdx = nodes.length - 1
|
|
82
|
+
for (let i = startIdx + 1; i < nodes.length; i++) { if (isRoundBoundary(events[nodes[i]])) { endIdx = i - 1; break } }
|
|
83
|
+
const span = nodes.slice(startIdx, endIdx + 1)
|
|
84
|
+
if (span.length === 0) return null
|
|
85
|
+
return { start: span[0], end: span[span.length - 1], shadowedSeqs: span }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* DSH 适配器:EventReader 实现。
|
|
90
|
+
* 从文件读全量事件(可靠事实),并提供基于它的遮蔽计算。
|
|
91
|
+
*/
|
|
92
|
+
export const dshAdapter = {
|
|
93
|
+
reader: { readEvents },
|
|
94
|
+
/** 便捷:读事件 + 算遮蔽一步到位(供 index.js/http.js 注入 args.span)。 */
|
|
95
|
+
async spanFromFile(sessionId, target, mode = 'round') {
|
|
96
|
+
const events = await readEvents(sessionId)
|
|
97
|
+
return computeSpan(events, target, mode)
|
|
98
|
+
},
|
|
99
|
+
/**
|
|
100
|
+
* 从文件全量事件算某 turn 内的最大 step 号(情形② marker step 分配用)。
|
|
101
|
+
* 绕开 host 窗口化 session.events(稀疏内存视图可能看不到 turn 内全部 step,
|
|
102
|
+
* 算小 → 新 step 号与窗口外既有 step 冲突 = step key 冲突白屏,1e99e1ff 复盘)。
|
|
103
|
+
* 失败返回 null(调用方 fallback 内存扫描)。
|
|
104
|
+
* @param {string} [filePath] 可选:直接指定会话文件路径(测试注入)。
|
|
105
|
+
*/
|
|
106
|
+
async maxStepInTurnFromFile(sessionId, turn, filePath) {
|
|
107
|
+
const events = filePath ? await readEventsFromFile(filePath) : await readEvents(sessionId)
|
|
108
|
+
if (!Array.isArray(events)) return null
|
|
109
|
+
let max = 0
|
|
110
|
+
for (const ev of events) {
|
|
111
|
+
if (ev?.type === 'step/start' && ev.data?.turn === turn) {
|
|
112
|
+
const step = ev.data?.step
|
|
113
|
+
if (typeof step === 'number' && Number.isSafeInteger(step) && step > max) max = step
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return max
|
|
117
|
+
},
|
|
118
|
+
}
|
package/lib/client.bundle.js
CHANGED
|
@@ -179,7 +179,9 @@ var zh = {
|
|
|
179
179
|
"fork.lineageRoot": "\u6839\u4F1A\u8BDD",
|
|
180
180
|
"fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002",
|
|
181
181
|
"fork.badge": "\u4F1A\u8BDD\u77ED\u7801",
|
|
182
|
-
"fork.badgeHint": "\u77ED\u7801\u7531\u4F1A\u8BDD\u552F\u4E00 id \u786E\u5B9A\u6027\u5BFC\u51FA\uFF0C\u6C38\u4E0D\u53D8\uFF08\u6807\u9898\u4F1A\u53D8\uFF09\u3002"
|
|
182
|
+
"fork.badgeHint": "\u77ED\u7801\u7531\u4F1A\u8BDD\u552F\u4E00 id \u786E\u5B9A\u6027\u5BFC\u51FA\uFF0C\u6C38\u4E0D\u53D8\uFF08\u6807\u9898\u4F1A\u53D8\uFF09\u3002",
|
|
183
|
+
"fork.rename": "\u6539\u540D\u79F0",
|
|
184
|
+
"fork.renamePrompt": "\u8F93\u5165\u4F1A\u8BDD\u540D\u79F0\uFF08\u81EA\u52A8\u62FC\u63A5\u77ED\u7801\uFF0C\u8BBE\u7F6E\u540E\u56FA\u5B9A\u4E0D\u518D\u81EA\u52A8\u6539\u540D\uFF09\uFF1A"
|
|
183
185
|
};
|
|
184
186
|
var en = {
|
|
185
187
|
"action.edit": "Edit",
|
|
@@ -275,7 +277,9 @@ var en = {
|
|
|
275
277
|
"fork.lineageRoot": "Root session",
|
|
276
278
|
"fork.lineageEmpty": "This session has no parent (standalone root).",
|
|
277
279
|
"fork.badge": "Session badge",
|
|
278
|
-
"fork.badgeHint": "A stable shortcode derived from the session id (titles change, badges never do)."
|
|
280
|
+
"fork.badgeHint": "A stable shortcode derived from the session id (titles change, badges never do).",
|
|
281
|
+
"fork.rename": "Rename",
|
|
282
|
+
"fork.renamePrompt": "Enter a session name (badge is prefixed automatically; the title is then pinned, no auto-rename):"
|
|
279
283
|
};
|
|
280
284
|
var SURFACE_TYPES = /* @__PURE__ */ new Set(["user/message", "assistant/message", "tool/result"]);
|
|
281
285
|
function isReplacementSurfaceEvent(event) {
|
|
@@ -1134,6 +1138,7 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1134
1138
|
const [gitBusy, setGitBusy] = (0, import_react.useState)(false);
|
|
1135
1139
|
const [preview, setPreview] = (0, import_react.useState)(null);
|
|
1136
1140
|
const [previewScope, setPreviewScope] = (0, import_react.useState)("both");
|
|
1141
|
+
const [badge, setBadge] = (0, import_react.useState)("");
|
|
1137
1142
|
const [rollbackBusy, setRollbackBusy] = (0, import_react.useState)(false);
|
|
1138
1143
|
const [scrollTop, setScrollTop] = (0, import_react.useState)(0);
|
|
1139
1144
|
const [doctor, setDoctor] = (0, import_react.useState)(null);
|
|
@@ -1157,6 +1162,14 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1157
1162
|
if (projected === void 0) refresh();
|
|
1158
1163
|
refreshGit();
|
|
1159
1164
|
timelineGet(`/doctor?sessionId=${encodeURIComponent(sessionId)}`).then((result) => setDoctor(result?.ok === true ? result.value : null)).catch(() => setDoctor(null));
|
|
1165
|
+
callOp("setBadgeTitle", { sessionId }).catch(() => {
|
|
1166
|
+
});
|
|
1167
|
+
callOp("sessionBadge", { sessionId }).then((result) => {
|
|
1168
|
+
if (result && result.ok === true && typeof result.value?.badge === "string") {
|
|
1169
|
+
setBadge(result.value.badge);
|
|
1170
|
+
}
|
|
1171
|
+
}).catch(() => {
|
|
1172
|
+
});
|
|
1160
1173
|
}, []);
|
|
1161
1174
|
const jump = (boundarySeq) => jumpToAnchor(store, boundarySeq);
|
|
1162
1175
|
const requestPreview = (record) => {
|
|
@@ -1200,6 +1213,8 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1200
1213
|
return (0, import_react.createElement)("div", { className: "dsh-rt-view" }, [
|
|
1201
1214
|
(0, import_react.createElement)("div", { key: "head", className: "dsh-rt-timeline-head" }, [
|
|
1202
1215
|
(0, import_react.createElement)("span", { key: "title", className: "dsh-rt-timeline-title" }, t("timeline.title")),
|
|
1216
|
+
// 会话铭牌(2026-09-01):所有界面统一展示短码,沟通任务用
|
|
1217
|
+
sessionId && (0, import_react.createElement)("code", { key: "badge", className: "dsh-rt-fork-badge-code", title: t("fork.badgeHint") }, `[${badge}]`),
|
|
1203
1218
|
(0, import_react.createElement)("button", {
|
|
1204
1219
|
key: "refresh",
|
|
1205
1220
|
type: "button",
|
|
@@ -1326,6 +1341,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1326
1341
|
const [error, setError] = (0, import_react.useState)(null);
|
|
1327
1342
|
const [scrollTop, setScrollTop] = (0, import_react.useState)(0);
|
|
1328
1343
|
const [lineage, setLineage] = (0, import_react.useState)(null);
|
|
1344
|
+
const [badge, setBadge] = (0, import_react.useState)("");
|
|
1329
1345
|
const projected = typeof useProjection === "function" ? useProjection("retrace/forkmap") : void 0;
|
|
1330
1346
|
const versions = typeof useProjection === "function" ? useProjection("retrace/versions") : void 0;
|
|
1331
1347
|
const markerBySeq = new Map((versions?.versions ?? []).map((v) => [v.boundarySeq, v.markerText]));
|
|
@@ -1346,6 +1362,30 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1346
1362
|
if (result && result.ok === true && Array.isArray(result.value)) setLineage(result.value);
|
|
1347
1363
|
}).catch(() => {
|
|
1348
1364
|
});
|
|
1365
|
+
callOp("sessionBadge", { sessionId }).then((result) => {
|
|
1366
|
+
if (result && result.ok === true && typeof result.value?.badge === "string") {
|
|
1367
|
+
setBadge(result.value.badge);
|
|
1368
|
+
if (store && typeof store.rename === "function") {
|
|
1369
|
+
const current = typeof store.getTitle === "function" ? store.getTitle() : null;
|
|
1370
|
+
const base = typeof current === "string" && current.trim() ? current : sessionId.slice(0, 16);
|
|
1371
|
+
const tagged = `[${result.value.badge}] ${base.replace(/^\[[a-z0-9]{6,}\]\s*/, "")}`;
|
|
1372
|
+
store.rename(tagged).catch(() => callOp("setBadgeTitle", { sessionId }).catch(() => {
|
|
1373
|
+
}));
|
|
1374
|
+
} else {
|
|
1375
|
+
callOp("setBadgeTitle", { sessionId }).catch(() => {
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
} else {
|
|
1379
|
+
callOp("setBadgeTitle", { sessionId }).catch(() => {
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
}).catch(() => {
|
|
1383
|
+
});
|
|
1384
|
+
if (!window.__dshRetraceBadgeInit) {
|
|
1385
|
+
window.__dshRetraceBadgeInit = true;
|
|
1386
|
+
callOp("initBadgeTitles", {}).catch(() => {
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1349
1389
|
}, []);
|
|
1350
1390
|
const nodes = fork?.nodes ?? [];
|
|
1351
1391
|
const boundaries = fork?.boundaries ?? [];
|
|
@@ -1373,8 +1413,27 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1373
1413
|
// 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
|
|
1374
1414
|
sessionId && (0, import_react.createElement)("div", { key: "badge", className: "dsh-rt-fork-badge", title: t("fork.badgeHint") }, [
|
|
1375
1415
|
(0, import_react.createElement)("span", { key: "tag", className: "dsh-rt-fork-badge-tag" }, t("fork.badge")),
|
|
1376
|
-
(0, import_react.createElement)("code", { key: "code", className: "dsh-rt-fork-badge-code" }, `[${
|
|
1377
|
-
(0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-badge-id" }, sessionId)
|
|
1416
|
+
(0, import_react.createElement)("code", { key: "code", className: "dsh-rt-fork-badge-code" }, `[${badge}]`),
|
|
1417
|
+
(0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-badge-id" }, sessionId),
|
|
1418
|
+
// 编辑名称(2026-09-01):用户可自定义会话名,自动拼短码 + 固定(停自动改名)
|
|
1419
|
+
(0, import_react.createElement)("button", {
|
|
1420
|
+
key: "rename",
|
|
1421
|
+
type: "button",
|
|
1422
|
+
className: "dsh-rt-chip",
|
|
1423
|
+
onClick: () => {
|
|
1424
|
+
const name2 = window.prompt(t("fork.renamePrompt"), "");
|
|
1425
|
+
if (name2 && name2.trim()) {
|
|
1426
|
+
const tagged = `[${badge}] ${name2.trim()}`;
|
|
1427
|
+
if (store && typeof store.rename === "function") {
|
|
1428
|
+
store.rename(tagged).catch(() => callOp("setUserTitle", { sessionId, title: name2.trim() }).catch(() => {
|
|
1429
|
+
}));
|
|
1430
|
+
} else {
|
|
1431
|
+
callOp("setUserTitle", { sessionId, title: name2.trim() }).catch(() => {
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}, t("fork.rename"))
|
|
1378
1437
|
]),
|
|
1379
1438
|
Array.isArray(lineage) && lineage.length > 1 && (0, import_react.createElement)("div", { key: "lineage", className: "dsh-rt-fork-lineage" }, [
|
|
1380
1439
|
(0, import_react.createElement)("div", { key: "lt", className: "dsh-rt-fork-spine-label" }, t("fork.lineage")),
|
package/lib/client.js
CHANGED
|
@@ -151,6 +151,8 @@ const zh = {
|
|
|
151
151
|
'fork.lineageEmpty': '本会话没有父会话(独立根会话)。',
|
|
152
152
|
'fork.badge': '会话短码',
|
|
153
153
|
'fork.badgeHint': '短码由会话唯一 id 确定性导出,永不变(标题会变)。',
|
|
154
|
+
'fork.rename': '改名称',
|
|
155
|
+
'fork.renamePrompt': '输入会话名称(自动拼接短码,设置后固定不再自动改名):',
|
|
154
156
|
}
|
|
155
157
|
/** English dictionary, checked complete against the zh key set. */
|
|
156
158
|
const en = {
|
|
@@ -248,6 +250,8 @@ const en = {
|
|
|
248
250
|
'fork.lineageEmpty': 'This session has no parent (standalone root).',
|
|
249
251
|
'fork.badge': 'Session badge',
|
|
250
252
|
'fork.badgeHint': 'A stable shortcode derived from the session id (titles change, badges never do).',
|
|
253
|
+
'fork.rename': 'Rename',
|
|
254
|
+
'fork.renamePrompt': 'Enter a session name (badge is prefixed automatically; the title is then pinned, no auto-rename):',
|
|
251
255
|
}
|
|
252
256
|
|
|
253
257
|
// ---------------------------------------------------------------------------
|
|
@@ -1428,6 +1432,9 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1428
1432
|
const [gitBusy, setGitBusy] = useState(false)
|
|
1429
1433
|
const [preview, setPreview] = useState(null)
|
|
1430
1434
|
const [previewScope, setPreviewScope] = useState('both')
|
|
1435
|
+
// 语义短码(2026-09-01):默认空,异步从 host 短码表拿(工作区+序号语义)。
|
|
1436
|
+
// 不用 FNV 兜底显示——修复线规则是 op065op016 这类语义码,不是随机 hash。
|
|
1437
|
+
const [badge, setBadge] = useState('')
|
|
1431
1438
|
const [rollbackBusy, setRollbackBusy] = useState(false)
|
|
1432
1439
|
const [scrollTop, setScrollTop] = useState(0)
|
|
1433
1440
|
const [doctor, setDoctor] = useState(null)
|
|
@@ -1467,6 +1474,14 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1467
1474
|
timelineGet(`/doctor?sessionId=${encodeURIComponent(sessionId)}`)
|
|
1468
1475
|
.then((result) => setDoctor(result?.ok === true ? result.value : null))
|
|
1469
1476
|
.catch(() => setDoctor(null))
|
|
1477
|
+
// 写入短码 title(一次:如果标题已含短码则跳过)
|
|
1478
|
+
callOp('setBadgeTitle', { sessionId }).catch(() => {})
|
|
1479
|
+
// 语义短码(2026-09-01):host 短码表优先(工作区+序号),FNV 兜底
|
|
1480
|
+
callOp('sessionBadge', { sessionId }).then((result) => {
|
|
1481
|
+
if (result && result.ok === true && typeof result.value?.badge === 'string') {
|
|
1482
|
+
setBadge(result.value.badge)
|
|
1483
|
+
}
|
|
1484
|
+
}).catch(() => {})
|
|
1470
1485
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1471
1486
|
}, [])
|
|
1472
1487
|
|
|
@@ -1529,6 +1544,8 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1529
1544
|
return createElement('div', { className: 'dsh-rt-view' }, [
|
|
1530
1545
|
createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
|
|
1531
1546
|
createElement('span', { key: 'title', className: 'dsh-rt-timeline-title' }, t('timeline.title')),
|
|
1547
|
+
// 会话铭牌(2026-09-01):所有界面统一展示短码,沟通任务用
|
|
1548
|
+
sessionId && createElement('code', { key: 'badge', className: 'dsh-rt-fork-badge-code', title: t('fork.badgeHint') }, `[${badge}]`),
|
|
1532
1549
|
createElement('button', {
|
|
1533
1550
|
key: 'refresh',
|
|
1534
1551
|
type: 'button',
|
|
@@ -1679,6 +1696,9 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1679
1696
|
const [error, setError] = useState(null)
|
|
1680
1697
|
const [scrollTop, setScrollTop] = useState(0)
|
|
1681
1698
|
const [lineage, setLineage] = useState(null)
|
|
1699
|
+
// 语义短码(2026-09-01):默认空,异步从 host 短码表拿(工作区+序号语义)。
|
|
1700
|
+
// 不用 FNV 兜底显示——修复线规则是 op065op016 这类语义码,不是随机 hash。
|
|
1701
|
+
const [badge, setBadge] = useState('')
|
|
1682
1702
|
|
|
1683
1703
|
// Live push-frame path (projection standard kit) — falls back to HTTP.
|
|
1684
1704
|
const projected = typeof useProjection === 'function' ? useProjection('retrace/forkmap') : undefined
|
|
@@ -1712,6 +1732,29 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1712
1732
|
if (result && result.ok === true && Array.isArray(result.value)) setLineage(result.value)
|
|
1713
1733
|
})
|
|
1714
1734
|
.catch(() => { /* lineage is a best-effort enrichment */ })
|
|
1735
|
+
// 语义短码(2026-09-01):host 短码表优先(工作区+序号),FNV 兜底
|
|
1736
|
+
callOp('sessionBadge', { sessionId }).then((result) => {
|
|
1737
|
+
if (result && result.ok === true && typeof result.value?.badge === 'string') {
|
|
1738
|
+
setBadge(result.value.badge)
|
|
1739
|
+
// 用官方 rename 更新 title(投影立即刷新,侧边栏/标题栏显示 [短码] 名称)
|
|
1740
|
+
// rename 会写 user source title → 停自动改名。若 rename 不可用,fallback host setBadgeTitle。
|
|
1741
|
+
if (store && typeof store.rename === 'function') {
|
|
1742
|
+
const current = typeof store.getTitle === 'function' ? store.getTitle() : null
|
|
1743
|
+
const base = (typeof current === 'string' && current.trim()) ? current : sessionId.slice(0, 16)
|
|
1744
|
+
const tagged = `[${result.value.badge}] ${base.replace(/^\[[a-z0-9]{6,}\]\s*/, '')}`
|
|
1745
|
+
store.rename(tagged).catch(() => callOp('setBadgeTitle', { sessionId }).catch(() => {}))
|
|
1746
|
+
} else {
|
|
1747
|
+
callOp('setBadgeTitle', { sessionId }).catch(() => {})
|
|
1748
|
+
}
|
|
1749
|
+
} else {
|
|
1750
|
+
callOp('setBadgeTitle', { sessionId }).catch(() => {})
|
|
1751
|
+
}
|
|
1752
|
+
}).catch(() => {})
|
|
1753
|
+
// 批量初始化所有会话标题短码(只跑一次,会话列表侧边栏直接显示)
|
|
1754
|
+
if (!window.__dshRetraceBadgeInit) {
|
|
1755
|
+
window.__dshRetraceBadgeInit = true
|
|
1756
|
+
callOp('initBadgeTitles', {}).catch(() => {})
|
|
1757
|
+
}
|
|
1715
1758
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1716
1759
|
}, [])
|
|
1717
1760
|
|
|
@@ -1757,8 +1800,25 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1757
1800
|
// 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
|
|
1758
1801
|
sessionId && createElement('div', { key: 'badge', className: 'dsh-rt-fork-badge', title: t('fork.badgeHint') }, [
|
|
1759
1802
|
createElement('span', { key: 'tag', className: 'dsh-rt-fork-badge-tag' }, t('fork.badge')),
|
|
1760
|
-
createElement('code', { key: 'code', className: 'dsh-rt-fork-badge-code' }, `[${
|
|
1803
|
+
createElement('code', { key: 'code', className: 'dsh-rt-fork-badge-code' }, `[${badge}]`),
|
|
1761
1804
|
createElement('span', { key: 'id', className: 'dsh-rt-fork-badge-id' }, sessionId),
|
|
1805
|
+
// 编辑名称(2026-09-01):用户可自定义会话名,自动拼短码 + 固定(停自动改名)
|
|
1806
|
+
createElement('button', {
|
|
1807
|
+
key: 'rename',
|
|
1808
|
+
type: 'button',
|
|
1809
|
+
className: 'dsh-rt-chip',
|
|
1810
|
+
onClick: () => {
|
|
1811
|
+
const name = window.prompt(t('fork.renamePrompt'), '')
|
|
1812
|
+
if (name && name.trim()) {
|
|
1813
|
+
const tagged = `[${badge}] ${name.trim()}`
|
|
1814
|
+
if (store && typeof store.rename === 'function') {
|
|
1815
|
+
store.rename(tagged).catch(() => callOp('setUserTitle', { sessionId, title: name.trim() }).catch(() => {}))
|
|
1816
|
+
} else {
|
|
1817
|
+
callOp('setUserTitle', { sessionId, title: name.trim() }).catch(() => {})
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
},
|
|
1821
|
+
}, t('fork.rename')),
|
|
1762
1822
|
]),
|
|
1763
1823
|
|
|
1764
1824
|
Array.isArray(lineage) && lineage.length > 1 && createElement('div', { key: 'lineage', className: 'dsh-rt-fork-lineage' }, [
|