dsh-rewind-plugin 0.1.9 → 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 +46 -46
- package/lib/client.js +99 -41
- package/lib/index.js +200 -157
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/styles.d.ts +2 -1
- package/lib/types/index.d.ts +39 -16
- package/lib/types/session-cwd.d.ts +1 -1
- package/lib/types/snapshot.d.ts +102 -0
- package/package.json +1 -1
- package/scripts/verify-host.mjs +115 -80
- package/lib/types/ledger.d.ts +0 -88
package/README.md
CHANGED
|
@@ -2,19 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
|
|
4
4
|
|
|
5
|
-
> 状态:v0.
|
|
5
|
+
> 状态:v0.2.0 已实现(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
|
|
6
6
|
|
|
7
|
-
## 实现状态(v0.
|
|
7
|
+
## 实现状态(v0.2.0)
|
|
8
8
|
|
|
9
|
-
- ✅ host 端 `/rewind`
|
|
10
|
-
- ✅
|
|
9
|
+
- ✅ host 端 `/rewind` 命令(参数化形式作为 ↶ 按钮的内部调用通道;手动输入由 client 拦截)
|
|
10
|
+
- ✅ **Claude Code 式 checkpoint 文件回退**:`tools/execute` 捕获写前备份、`tools/post-execute` 按消息分组**落盘**提交(`~/.dsh/rewind-snapshots`),重启后仍可还原
|
|
11
11
|
- ✅ **与其他审批类插件共存**:捕获在 around-dispatch 阶段,`tools/pre-execute` 被 `ask` 短路(如 dsh-edit-approval)后批准仍能记录;被拒绝的调用不留 pending 残留
|
|
12
|
-
- ✅ **路径按会话 cwd 解析**(复刻 `dsh-tool-fs` 的 session-cwd
|
|
12
|
+
- ✅ **路径按会话 cwd 解析**(复刻 `dsh-tool-fs` 的 session-cwd 规则),相对路径备份/还原指向真实文件;记录解析后的 display path
|
|
13
13
|
- ✅ **fs 服务动态获取**(`ctx.inject(['fs'])`):fs 后挂载也不失效,无 fs 部署时命令仍可用
|
|
14
14
|
- ✅ 同窗口 in-place 回退:追加空内容标记 + `surfaceOp: replace` 替换目标及之后全部 surface(真实 `dsh-session` 集成测试通过)
|
|
15
|
-
- ✅
|
|
16
|
-
- ✅
|
|
17
|
-
-
|
|
15
|
+
- ✅ **还原走 `node:fs` 直写**(不经 fs 服务):文件内容真正落盘;符号链接跳过并警告
|
|
16
|
+
- ✅ client 端「回退」按钮(MutationObserver 注入用户消息行操作区)+ 模式选择浮层(含 both 模式影响清单确认)+ 手动 `/rewind` 输入拦截
|
|
17
|
+
- ✅ 测试:纯函数单测 + 真实 `dsh-session` 集成测试 + `verify-host` 端到端(18 项,含审批短路/会话 cwd/新建文件删除/重启持久化场景)
|
|
18
|
+
- ⏳ 二期:快捷键、bash/外部修改的快照覆盖(整树快照)
|
|
18
19
|
|
|
19
20
|
## 安装
|
|
20
21
|
|
|
@@ -68,24 +69,24 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
68
69
|
|
|
69
70
|
- 每条用户消息 hover 出现「↶ 回退」按钮:点击 → 选择「仅回退对话」或
|
|
70
71
|
「回退对话和代码」(后者先展示影响清单再确认)。
|
|
71
|
-
- **回退 =
|
|
72
|
+
- **回退 = 撤回(时间回溯)**:对**任意**用户消息回退(使用消息旁的 ↶ 按钮),
|
|
72
73
|
效果是**撤回该消息及它之后的所有内容**(含 agent 回复、工具调用)——对话界面与
|
|
73
74
|
Agent 上下文都回到这条消息发送之前;**该消息的文本自动填入输入框(编辑区)**,
|
|
74
75
|
可直接修改后重发。命令结果提示"已撤回 seq N 及之后内容"。
|
|
75
|
-
-
|
|
76
|
-
|
|
76
|
+
- **手动 `/rewind` 不支持**:在输入框手动输入 `/rewind`(含裸命令)会被 client 拦截并
|
|
77
|
+
提示改用按钮——`/rewind` 命令仅作为按钮的内部调用通道存在。
|
|
77
78
|
- **回退后前端与 Agent 一致**:回退标记是空内容消息(deriveMessages 会跳过,模型
|
|
78
79
|
上下文无任何标记噪音);client 端隐藏被撤回范围内的消息行与 `/rewind` 命令结果,
|
|
79
80
|
可见对话即"撤回点之前的内容"。会话日志(append-only 审计)不受影响。
|
|
80
81
|
|
|
81
|
-
## 已知限制(v0.
|
|
82
|
+
## 已知限制(v0.2)
|
|
82
83
|
|
|
83
|
-
-
|
|
84
|
-
bash
|
|
85
|
-
|
|
86
|
-
-
|
|
84
|
+
- checkpoint 只覆盖**插件运行期间、经 `write` / `edit` / `str_replace_editor` 的变更**;
|
|
85
|
+
bash 或外部程序的修改无法还原(与 Claude Code 相同的限制,二期可加整树快照层)。
|
|
86
|
+
备份按消息分组**落盘**(每会话保留最近 100 组,最旧先清理),dsh 重启不丢失。
|
|
87
|
+
- 文件删除/还原走真实路径直删直写(本地 backend);sandbox/远程 backend 下路径解析
|
|
87
88
|
可能受限。
|
|
88
|
-
-
|
|
89
|
+
- 回退本身可再回退(标记进入日志),但文件还原动作不再记录新备份。
|
|
89
90
|
- 回退按钮只出现在**当前会话**渲染的用户消息行上(DOM 注入范围即当前视图);
|
|
90
91
|
subagent/分屏等非当前会话的对话需要先切到该会话再回退。
|
|
91
92
|
|
|
@@ -93,9 +94,10 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
93
94
|
|
|
94
95
|
社区 rewind 类插件(`dsh-recall-plugin`、`dsh-checkpoint-rewind`、`dsh-turn-rewind`)均为 **fork 路线**(回退 = fork 出新会话,用户切换会话继续),且没有「仅回退对话 / 对话+代码」的选项。本插件提供:**在当前会话窗口内**改写模型上下文 + 可选还原工作区文件。
|
|
95
96
|
|
|
96
|
-
##
|
|
97
|
+
## 交互设计(按钮两步;手动命令不支持)
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
按钮流程遵循两步:**第一步选择要回退到的 user 消息,第二步选择回退模式**。手动
|
|
100
|
+
`/rewind` 命令不支持输入——client 端在输入框拦截所有手动 `/rewind` 并提示改用按钮。
|
|
99
101
|
|
|
100
102
|
### 1. 用户消息旁的「回退」按钮(主入口)
|
|
101
103
|
|
|
@@ -108,18 +110,11 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
108
110
|
- 选「回退对话和代码」时,浮层内先显示将受影响的内容清单(将还原/删除的文件名与数量),确认后执行。
|
|
109
111
|
- 执行结果以一条对话内消息呈现(如「已回退到 seq N,移除 M 条上下文;还原 2 个文件」)。
|
|
110
112
|
|
|
111
|
-
### 2.
|
|
113
|
+
### 2. 命令(仅作按钮内部通道,不支持手动输入)
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
/rewind <序号|@seq> chat 执行:仅回退对话
|
|
117
|
-
/rewind <序号|@seq> both 执行:对话 + 代码
|
|
118
|
-
/rewind preview <目标> 只输出影响清单,不执行
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
- 分步示例:`/rewind` → 返回「1. 14:02 … / 2. 13:47 …」→ 输 `/rewind 1` → 返回「回退到消息 1:/rewind 1 chat 或 /rewind 1 both」→ 输 `/rewind 1 both` 执行。
|
|
122
|
-
- 若输入直接带全参数(`/rewind 1 both`),等价于跳过前两步直接执行——高级用法,不强制。
|
|
115
|
+
- **手动输入 `/rewind`(含裸命令)会被 client 端在输入框直接拦截**,并提示改用消息旁
|
|
116
|
+
的 ↶ 按钮。`/rewind` 命令仅作为按钮的内部调用通道存在
|
|
117
|
+
(`/rewind @seq chat|both`、`/rewind preview @seq both`)。
|
|
123
118
|
- UI 按钮与命令共享同一套 host 端回退逻辑(`/rewind @seq <mode>`)。
|
|
124
119
|
|
|
125
120
|
## 回退机制(host 端,全部公开 API)
|
|
@@ -130,30 +125,35 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
130
125
|
- 效果:当前窗口上下文从目标点重新开始;**不产生新会话、不切换窗口**;原始日志完整保留(append-only 审计不变),仅不再进入模型上下文。
|
|
131
126
|
- 依赖:`@deepseek-ai/dsh-session`(`Session.append`、`foldSurface`)、`@deepseek-ai/dsh-llm`(`createUserMessage`)、`@deepseek-ai/dsh-commands`(命令注册)、`@deepseek-ai/dsh-agent`(`Agent.status` idle 守卫)。
|
|
132
127
|
|
|
133
|
-
### 4.
|
|
128
|
+
### 4. 文件回退:Claude Code 式 checkpoint(写前备份,按消息分组落盘)
|
|
134
129
|
|
|
135
|
-
- 在 `tools/execute`(around-dispatch
|
|
136
|
-
`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
130
|
+
- 在 `tools/execute`(around-dispatch 阶段)读取目标文件**写前备份**(before;文件不存在记
|
|
131
|
+
为「新建」),`tools/post-execute` 将备份按**当前轮用户消息 seq(锚点)**落盘提交到
|
|
132
|
+
`~/.dsh/rewind-snapshots/<会话>/<锚点 seq>/`。捕获放在 execute 而非 pre-execute:
|
|
133
|
+
**审批类插件(如 dsh-edit-approval)在 `tools/pre-execute` 返回 `ask` 会短路后续监听器**,
|
|
134
|
+
但批准后 dispatch 阶段必然执行——共存的写操作照样入备份;被拒绝的调用不 dispatch,
|
|
135
|
+
不会留下残留。
|
|
140
136
|
- 相对路径按**会话 cwd** 解析(与 `dsh-tool-fs` 同规则,`src/session-cwd.ts`),
|
|
141
|
-
|
|
142
|
-
-
|
|
143
|
-
|
|
137
|
+
备份记录解析后的 display path,preview/还原始终指向真实文件。
|
|
138
|
+
- 回退「对话和代码」到消息 N 时:对锚点 ≥ N 的每条备份取**该文件最早一条**——内容写回
|
|
139
|
+
before、新建文件删除(与 Claude Code 的 rewind 语义一致)。恢复用 `node:fs` 直写真实
|
|
140
|
+
文件,不经过 fs 服务;符号链接跳过并在结果中提示。
|
|
141
|
+
- 持久化:备份在磁盘上,**dsh 重启后仍可还原**;每会话保留最近 100 个消息分组,最旧先清理。
|
|
142
|
+
- 边界(已知限制):只覆盖**插件运行期间、经写类工具**的变更;bash 命令或外部程序的修改
|
|
143
|
+
不在备份内,无法还原(与 Claude Code 相同的限制,二期可加整树快照层)。
|
|
144
144
|
|
|
145
145
|
### 5. 安全守卫
|
|
146
146
|
|
|
147
147
|
- agent 运行中(LLM 思考/输出)执行回退时**自动强制停止**当前回合(`cancel({kind:'user'})`),
|
|
148
148
|
等待 quiescence 后回退;停止超时/失败则中止并报错。无需先手动停止。
|
|
149
149
|
- 文件还原是破坏性操作:UI 选择「对话和代码」时需经影响清单确认;命令路径用 `preview` 先行查看。
|
|
150
|
-
-
|
|
150
|
+
- 回退本身可再回退(回退动作同样进入会话日志),但文件还原动作不再记录新备份。
|
|
151
151
|
|
|
152
152
|
## 客户端实现要点(纯插件,无源码补丁)
|
|
153
153
|
|
|
154
154
|
- 按钮注入锚点:用户行 `[data-chat-flow-kind="user"]`(行容器 `data-chat-anchor-key` 为节点 key);用 MutationObserver 跟踪新增行。
|
|
155
155
|
- 消息 seq 获取:从行元素的 `data-chat-anchor-key` → 运行时快照 `session.getSnapshot().chat.nodes.get(key)` → `UserMessageNode.seq`(DOM 只用于定位,数据取自 runtime,不解析 DOM 文本)。
|
|
156
|
-
-
|
|
156
|
+
- **按钮两步选择浮层**:点击消息旁按钮时,客户端接管交互——目标即该消息,第二步展示模式选项(仅回退对话 / 回退对话和代码 / 取消);确认后调 `session.command('/rewind @<seq> <mode>')` 执行。**手动 `/rewind` 被 client 整体拦截**(含裸命令):输入框 guard 阻止提交并提示改用按钮;`/rewind` 命令仅作为按钮内部调用通道存在。
|
|
157
157
|
- 执行结果以命令节点出现在对话中。
|
|
158
158
|
- 注入按钮与「在新对话中分支」等官方操作并排,样式遵循 dsh 设计 token。
|
|
159
159
|
|
|
@@ -162,22 +162,22 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
162
162
|
- 快捷键(esc+esc 回退等)——独立的快捷键插件,二期。
|
|
163
163
|
- 压缩(`/compact`)——官方已有。
|
|
164
164
|
- fork/分支回退——官方已有(「在新对话中分支」)。
|
|
165
|
-
-
|
|
165
|
+
- 快照式文件回退(整树/git-first,覆盖 bash 与外部修改)——二期(checkpoint 方案先行)。
|
|
166
166
|
|
|
167
167
|
## 目录结构(实际)
|
|
168
168
|
|
|
169
169
|
```
|
|
170
|
-
src/index.ts host 插件:/rewind 命令 + tools/execute|post-execute
|
|
170
|
+
src/index.ts host 插件:/rewind 命令 + tools/execute|post-execute checkpoint(fs 动态注入)
|
|
171
171
|
src/rewind.ts planRewind 纯函数(目标解析、surface 范围计算、候选列表)
|
|
172
|
-
src/
|
|
172
|
+
src/snapshot.ts checkpoint 存储(写前备份按消息分组落盘、还原/删除/影响清单、有界清理)
|
|
173
173
|
src/session-cwd.ts 会话 cwd 解析(复刻 dsh-tool-fs 规则,可单测)
|
|
174
174
|
src/client/index.ts client 插件:消息行「回退」按钮 + 模式选择浮层
|
|
175
175
|
src/client/popover.ts 浮层 DOM(含 both 模式影响清单确认)
|
|
176
176
|
src/client/locales.ts zh/en 文案(LocaleNamespaceMap 合并)
|
|
177
177
|
src/client/styles.ts 注入样式(dsh 设计 token)
|
|
178
178
|
scripts/build.mjs esbuild 构建:lib/index.js(host ESM)+ lib/client.js(loader 闭包)
|
|
179
|
-
scripts/verify-host.mjs 端到端验证(真实 cordis + dsh-session
|
|
180
|
-
tests/ rewind/
|
|
179
|
+
scripts/verify-host.mjs 端到端验证(真实 cordis + dsh-session + 真实临时文件,18 项断言)
|
|
180
|
+
tests/ rewind/snapshot 单测 + 真实 dsh-session 集成测试
|
|
181
181
|
cordis.patch.yml bundle patch(插入 dsh-rewind-plugin 一行,双面)
|
|
182
182
|
package.json dsh.bundle + dsh.client 声明、optional peerDependencies
|
|
183
183
|
```
|
package/lib/client.js
CHANGED
|
@@ -45,9 +45,10 @@ var zh = {
|
|
|
45
45
|
"popover.cancel": "\u53D6\u6D88",
|
|
46
46
|
"popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
|
|
47
47
|
"popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
|
|
48
|
-
"popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\
|
|
48
|
+
"popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u8DDF\u8E2A\u5230\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002",
|
|
49
49
|
"popover.confirm": "\u786E\u8BA4\u56DE\u9000",
|
|
50
|
-
"popover.back": "\u8FD4\u56DE"
|
|
50
|
+
"popover.back": "\u8FD4\u56DE",
|
|
51
|
+
"guard.hint": "/rewind \u547D\u4EE4\u4E0D\u652F\u6301\u624B\u52A8\u8F93\u5165\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684 \u21B6 \u56DE\u9000\u6309\u94AE"
|
|
51
52
|
};
|
|
52
53
|
var en = {
|
|
53
54
|
"button.aria": "Rewind to this message",
|
|
@@ -61,9 +62,10 @@ var en = {
|
|
|
61
62
|
"popover.cancel": "Cancel",
|
|
62
63
|
"popover.impact.loading": "Fetching impact list\u2026",
|
|
63
64
|
"popover.impact.failed": "Could not fetch the impact list: {message}",
|
|
64
|
-
"popover.impact.none": "No
|
|
65
|
+
"popover.impact.none": "No tracked file changes after the target; nothing to restore.",
|
|
65
66
|
"popover.confirm": "Confirm rewind",
|
|
66
|
-
"popover.back": "Back"
|
|
67
|
+
"popover.back": "Back",
|
|
68
|
+
"guard.hint": "/rewind cannot be typed manually \u2014 use the \u21B6 button on a message to rewind."
|
|
67
69
|
};
|
|
68
70
|
|
|
69
71
|
// src/client/styles.ts
|
|
@@ -79,7 +81,8 @@ var CLASS = {
|
|
|
79
81
|
popoverImpact: "dsh-rewind-popover-impact",
|
|
80
82
|
popoverActions: "dsh-rewind-popover-actions",
|
|
81
83
|
popoverPrimary: "dsh-rewind-popover-primary",
|
|
82
|
-
popoverGhost: "dsh-rewind-popover-ghost"
|
|
84
|
+
popoverGhost: "dsh-rewind-popover-ghost",
|
|
85
|
+
guardHint: "dsh-rewind-guard-hint"
|
|
83
86
|
};
|
|
84
87
|
var REWIND_ICON_SVG = [
|
|
85
88
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">',
|
|
@@ -198,6 +201,20 @@ var STYLE = `
|
|
|
198
201
|
.dsh-rewind-popover-ghost:hover {
|
|
199
202
|
background: var(--dsw-alias-interactive-bg-hover);
|
|
200
203
|
}
|
|
204
|
+
|
|
205
|
+
.dsh-rewind-guard-hint {
|
|
206
|
+
position: fixed;
|
|
207
|
+
z-index: 1000;
|
|
208
|
+
max-width: min(440px, calc(100vw - 24px));
|
|
209
|
+
padding: 8px 12px;
|
|
210
|
+
border-radius: 10px;
|
|
211
|
+
background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));
|
|
212
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
|
|
213
|
+
font-size: 13px;
|
|
214
|
+
line-height: 18px;
|
|
215
|
+
color: var(--dsw-alias-label-primary);
|
|
216
|
+
pointer-events: none;
|
|
217
|
+
}
|
|
201
218
|
`;
|
|
202
219
|
|
|
203
220
|
// src/client/popover.ts
|
|
@@ -406,10 +423,11 @@ function hiddenSeqsOf(session) {
|
|
|
406
423
|
const command = node.data;
|
|
407
424
|
if (command.name !== "rewind") continue;
|
|
408
425
|
if (command.outcome?.kind !== "success") continue;
|
|
426
|
+
if (command.outcome.sourceEventSeq === void 0) continue;
|
|
409
427
|
hidden.add(command.seq);
|
|
410
|
-
const marker = command.outcome
|
|
411
|
-
const target = targetOfOutcome(command.outcome
|
|
412
|
-
if (
|
|
428
|
+
const marker = command.outcome.sourceEventSeq;
|
|
429
|
+
const target = targetOfOutcome(command.outcome.text);
|
|
430
|
+
if (target !== void 0 && (latest === null || marker > latest.marker)) {
|
|
413
431
|
latest = { marker, target };
|
|
414
432
|
}
|
|
415
433
|
}
|
|
@@ -501,45 +519,27 @@ function apply(ctx) {
|
|
|
501
519
|
buttons.set(key, button);
|
|
502
520
|
seat.setAttribute(REWIND_ATTACHED, "");
|
|
503
521
|
};
|
|
504
|
-
let lastRewindSeq = -1;
|
|
505
|
-
let hiddenSeqsCache = null;
|
|
506
522
|
const scan = () => {
|
|
507
523
|
for (const [key, button] of buttons) {
|
|
508
524
|
if (!button.isConnected) buttons.delete(key);
|
|
509
525
|
}
|
|
510
526
|
const session = sessionFor();
|
|
511
|
-
|
|
512
|
-
if (session !== void 0) {
|
|
513
|
-
const snap = session.getSnapshot();
|
|
514
|
-
for (const key of snap.chat.order) {
|
|
515
|
-
const node = snap.chat.nodes.get(key);
|
|
516
|
-
if (node === void 0 || node.kind !== "command") continue;
|
|
517
|
-
const command = node.data;
|
|
518
|
-
if (command.name === "rewind" && command.outcome?.kind === "success" && command.seq > newestRewind) {
|
|
519
|
-
newestRewind = command.seq;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
if (newestRewind > lastRewindSeq) {
|
|
524
|
-
lastRewindSeq = newestRewind;
|
|
525
|
-
hiddenSeqsCache = session !== void 0 ? hiddenSeqsOf(session) : null;
|
|
526
|
-
if (session !== void 0) fillComposerForRewind(session, filledTargets);
|
|
527
|
-
}
|
|
528
|
-
const hiddenSeqs = hiddenSeqsCache;
|
|
527
|
+
const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session) : /* @__PURE__ */ new Set();
|
|
529
528
|
let hiddenCount = 0;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
hidden.delete(seat);
|
|
541
|
-
}
|
|
529
|
+
for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
530
|
+
const key = seat.dataset.chatAnchorKey;
|
|
531
|
+
const anchor = key !== void 0 && session !== void 0 ? session.getSnapshot().chat.nodes.get(key)?.anchorSeq : void 0;
|
|
532
|
+
if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
|
|
533
|
+
seat.style.display = "none";
|
|
534
|
+
hidden.add(seat);
|
|
535
|
+
hiddenCount += 1;
|
|
536
|
+
} else if (hidden.has(seat)) {
|
|
537
|
+
seat.style.display = "";
|
|
538
|
+
hidden.delete(seat);
|
|
542
539
|
}
|
|
540
|
+
}
|
|
541
|
+
if (session !== void 0) fillComposerForRewind(session, filledTargets);
|
|
542
|
+
if (hiddenSeqs.size > 0 || hiddenCount > 0) {
|
|
543
543
|
console.info(
|
|
544
544
|
`[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
|
|
545
545
|
);
|
|
@@ -556,6 +556,7 @@ function apply(ctx) {
|
|
|
556
556
|
if (node === void 0 || node.kind !== "command") continue;
|
|
557
557
|
const command = node.data;
|
|
558
558
|
if (command.name !== "rewind" || command.outcome?.kind !== "success") continue;
|
|
559
|
+
if (command.outcome.sourceEventSeq === void 0) continue;
|
|
559
560
|
const target = targetOfOutcome(command.outcome.text);
|
|
560
561
|
if (target === void 0 || filled.has(target)) continue;
|
|
561
562
|
const text = userTextAt(session, target);
|
|
@@ -563,10 +564,67 @@ function apply(ctx) {
|
|
|
563
564
|
if (fillComposer(text)) filled.add(target);
|
|
564
565
|
}
|
|
565
566
|
};
|
|
567
|
+
const MANUAL_REWIND = /^\s*\/rewind(?:\s|$)/i;
|
|
568
|
+
const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR);
|
|
569
|
+
const hasBlockedRewindDraft = () => {
|
|
570
|
+
const textarea = composerTextarea();
|
|
571
|
+
return textarea !== null && MANUAL_REWIND.test(textarea.value);
|
|
572
|
+
};
|
|
573
|
+
let guardHintEl = null;
|
|
574
|
+
let guardHintTimer;
|
|
575
|
+
const showGuardHint = () => {
|
|
576
|
+
if (guardHintEl !== null) guardHintEl.remove();
|
|
577
|
+
if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
|
|
578
|
+
const textarea = composerTextarea();
|
|
579
|
+
if (textarea === null) return;
|
|
580
|
+
const card = textarea.closest("[data-composer-card]");
|
|
581
|
+
const hint = document.createElement("div");
|
|
582
|
+
hint.className = CLASS.guardHint;
|
|
583
|
+
hint.setAttribute("role", "status");
|
|
584
|
+
hint.textContent = t("guard.hint");
|
|
585
|
+
document.body.appendChild(hint);
|
|
586
|
+
const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : textarea.getBoundingClientRect();
|
|
587
|
+
hint.style.left = `${Math.round(rect.left)}px`;
|
|
588
|
+
hint.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
|
|
589
|
+
guardHintEl = hint;
|
|
590
|
+
guardHintTimer = window.setTimeout(() => {
|
|
591
|
+
hint.remove();
|
|
592
|
+
if (guardHintEl === hint) guardHintEl = null;
|
|
593
|
+
guardHintTimer = void 0;
|
|
594
|
+
}, 3200);
|
|
595
|
+
};
|
|
596
|
+
const onKeyDownGuard = (event) => {
|
|
597
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
598
|
+
if (!hasBlockedRewindDraft()) return;
|
|
599
|
+
event.preventDefault();
|
|
600
|
+
event.stopPropagation();
|
|
601
|
+
showGuardHint();
|
|
602
|
+
};
|
|
603
|
+
const onClickGuard = (event) => {
|
|
604
|
+
if (event.button !== 0 || !hasBlockedRewindDraft()) return;
|
|
605
|
+
const target = event.target;
|
|
606
|
+
if (!(target instanceof Element)) return;
|
|
607
|
+
const button = target.closest("button");
|
|
608
|
+
if (button === null) return;
|
|
609
|
+
const card = button.closest("[data-composer-card]");
|
|
610
|
+
if (card === null) return;
|
|
611
|
+
const all = card.querySelectorAll("button");
|
|
612
|
+
if (all[all.length - 1] !== button) return;
|
|
613
|
+
if (button.querySelector("rect") !== null) return;
|
|
614
|
+
event.preventDefault();
|
|
615
|
+
event.stopPropagation();
|
|
616
|
+
showGuardHint();
|
|
617
|
+
};
|
|
618
|
+
document.addEventListener("keydown", onKeyDownGuard, true);
|
|
619
|
+
document.addEventListener("click", onClickGuard, true);
|
|
566
620
|
observer = new MutationObserver(scan);
|
|
567
|
-
observer.observe(document.body, { childList: true, subtree: true });
|
|
621
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
|
|
568
622
|
scan();
|
|
569
623
|
yield () => {
|
|
624
|
+
document.removeEventListener("keydown", onKeyDownGuard, true);
|
|
625
|
+
document.removeEventListener("click", onClickGuard, true);
|
|
626
|
+
if (guardHintEl !== null) guardHintEl.remove();
|
|
627
|
+
if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
|
|
570
628
|
observer?.disconnect();
|
|
571
629
|
for (const button of buttons.values()) button.remove();
|
|
572
630
|
buttons.clear();
|