dsh-rewind-plugin 0.5.0 → 0.6.1
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.en.md +18 -3
- package/README.md +4 -4
- package/SECURITY.md +9 -3
- package/docs/README.md +1 -0
- package/docs/compat/audit.md +152 -124
- package/docs/format.md +5 -4
- package/docs/harness-reference.md +1 -1
- package/docs/release/release.md +8 -3
- package/docs/release/release.zh.md +4 -2
- package/docs/snapshot-auto-cleanup.md +75 -56
- package/docs/snapshot-auto-cleanup.zh.md +36 -21
- package/lib/client.js +75 -112
- package/lib/index.js +206 -39
- package/lib/types/client/hidden.d.ts +28 -0
- package/lib/types/client/index.d.ts +12 -12
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/popover.d.ts +9 -2
- package/lib/types/client/portals.d.ts +15 -2
- package/lib/types/index.d.ts +6 -3
- package/lib/types/locales.d.ts +5 -0
- package/lib/types/snapshot-cleanup.d.ts +28 -8
- package/lib/types/snapshot.d.ts +87 -2
- package/package.json +7 -1
|
@@ -1,71 +1,90 @@
|
|
|
1
|
-
# Snapshot
|
|
1
|
+
# Snapshot cleanup
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
the store can
|
|
7
|
-
still grow without bound. `snapshot-auto-cleanup` is an OPTIONAL global policy
|
|
8
|
-
(off by default) that removes the whole snapshot directory of a session that has
|
|
9
|
-
been **long-inactive** — untouched past a configurable idle cutoff.
|
|
3
|
+
The plugin saves a backup of each file before it's edited, so you can rewind your
|
|
4
|
+
code to an earlier point. These backups are called **snapshots**, and they're
|
|
5
|
+
grouped by message and stored per session.
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
`/snapshot-auto-cleanup` helps you manage those snapshots — whether you want to
|
|
8
|
+
reclaim disk space from old sessions or clear the **current** session so its
|
|
9
|
+
rewind history starts fresh.
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
**Automatic cleanup** (off by default): the plugin can remember which sessions
|
|
14
|
+
you've stopped using, and now and then remove their snapshots to keep disk usage
|
|
15
|
+
down. It never touches your active session, and it never touches your
|
|
16
|
+
conversation.
|
|
17
|
+
|
|
18
|
+
**Manual:** even with automatic cleanup off, you can run the cleanup yourself:
|
|
19
|
+
|
|
20
|
+
- `/snapshot-auto-cleanup run [--apply]` — preview, then actually remove the
|
|
21
|
+
snapshots of sessions you haven't used for a while.
|
|
22
|
+
- `/snapshot-auto-cleanup run --current [--apply]` — preview, then actually
|
|
23
|
+
clear the **current** session's snapshots. This resets its rewind history to
|
|
24
|
+
"from now on" (your conversation is unaffected). If a turn is currently
|
|
25
|
+
running, the plugin pauses it first, then clears.
|
|
14
26
|
|
|
15
27
|
## Commands
|
|
16
28
|
|
|
17
29
|
```
|
|
18
|
-
/snapshot-auto-cleanup
|
|
19
|
-
/snapshot-auto-cleanup on|off
|
|
20
|
-
/snapshot-auto-cleanup max-age <days>
|
|
21
|
-
/snapshot-auto-cleanup run
|
|
22
|
-
/snapshot-auto-cleanup run --apply
|
|
30
|
+
/snapshot-auto-cleanup show the current settings
|
|
31
|
+
/snapshot-auto-cleanup on|off turn automatic cleanup on or off
|
|
32
|
+
/snapshot-auto-cleanup max-age <days> how many idle days before a session's snapshots are removed
|
|
33
|
+
/snapshot-auto-cleanup run preview what the automatic cleanup would remove
|
|
34
|
+
/snapshot-auto-cleanup run --apply actually remove those snapshots
|
|
35
|
+
/snapshot-auto-cleanup run --current preview clearing this session's snapshots
|
|
36
|
+
/snapshot-auto-cleanup run --current --apply actually clear this session's snapshots
|
|
23
37
|
```
|
|
24
38
|
|
|
25
|
-
`run`
|
|
26
|
-
|
|
39
|
+
`run` always starts as a preview; add `--apply` to make the change. `run` works
|
|
40
|
+
whether or not automatic cleanup is on.
|
|
27
41
|
|
|
28
|
-
##
|
|
42
|
+
## Settings
|
|
29
43
|
|
|
30
|
-
The
|
|
44
|
+
The settings live in `<dsh home>/snapshot-cleanup.json`:
|
|
31
45
|
|
|
32
46
|
```json
|
|
33
47
|
{ "enabled": false, "maxAgeDays": 30 }
|
|
34
48
|
```
|
|
35
49
|
|
|
36
|
-
- `enabled` — whether
|
|
37
|
-
- `maxAgeDays` — how many idle days before a
|
|
38
|
-
|
|
39
|
-
delete everything
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
The file is
|
|
43
|
-
reads as the safe default (off)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
50
|
+
- `enabled` — whether automatic cleanup runs (default `false`).
|
|
51
|
+
- `maxAgeDays` — how many idle days before a session's snapshots are removed
|
|
52
|
+
(default `30`). Only positive numbers are accepted, so a broken setting can
|
|
53
|
+
never delete everything.
|
|
54
|
+
|
|
55
|
+
You can point the plugin at a different file with the `DSH_SNAPSHOT_CLEANUP_CONFIG`
|
|
56
|
+
environment variable. The file is changed only when you turn cleanup on/off or
|
|
57
|
+
set `max-age`; a missing file reads as the safe default (off).
|
|
58
|
+
|
|
59
|
+
## When automatic cleanup runs
|
|
60
|
+
|
|
61
|
+
Automatic cleanup checks at most **once per run** (a restart lets it check again),
|
|
62
|
+
on the first session activity (a message you send or a tool call that finishes),
|
|
63
|
+
and only when it's enabled and at least 24 hours have passed since the last
|
|
64
|
+
check. The 24-hour clock is saved to `<dsh home>/snapshot-cleanup-last-sweep.json`,
|
|
65
|
+
so restarting doesn't reset it. It runs in the background and never blocks
|
|
66
|
+
what you're doing.
|
|
67
|
+
|
|
68
|
+
If you want a change to take effect right away, use `run`; automatic cleanup
|
|
69
|
+
picks up a fresh setting on the next run.
|
|
70
|
+
|
|
71
|
+
## Safety
|
|
72
|
+
|
|
73
|
+
- Only rewind **snapshots** (the file backups) are ever removed. Your
|
|
74
|
+
conversation is never touched, and the plugin never rewrites or deletes your
|
|
75
|
+
session history.
|
|
76
|
+
- Automatic cleanup never removes your **active** session's snapshots — only
|
|
77
|
+
sessions that have been idle past the cutoff.
|
|
78
|
+
- `run --current` clears the current session's snapshots. This is one-way for
|
|
79
|
+
that session's file-rewind history: you can't rewind code to before the clear,
|
|
80
|
+
but your conversation stays intact, and the session starts recording fresh
|
|
81
|
+
snapshots from now on.
|
|
82
|
+
|
|
83
|
+
## Known limitation
|
|
84
|
+
|
|
85
|
+
The plugin keeps the most recent **100 messages'** snapshots per session. If you
|
|
86
|
+
rewind or compact a lot in one long session, those 100 slots can be taken up by
|
|
87
|
+
messages that are no longer reachable, so you may find you can't rewind as far
|
|
88
|
+
back as you'd like. (Claude Code behaves the same way.) To get back to a clean
|
|
89
|
+
state, run `/snapshot-auto-cleanup run --current --apply` to clear the current
|
|
90
|
+
session and start fresh.
|
|
@@ -1,42 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 快照清理
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
插件会在每次修改文件前保存一份备份,方便你把代码回退到之前的状态。这些备份叫**快照**,按消息分组、按会话存放。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`/snapshot-auto-cleanup` 帮你管理这些快照——既可以回收旧会话占用的磁盘,也可以清空**当前**会话、让它从"现在"重新开始记录回退历史。
|
|
6
|
+
|
|
7
|
+
## 它能做什么
|
|
8
|
+
|
|
9
|
+
**自动清理**(默认关闭):插件会记住哪些会话你已不再使用,并定期移除它们的快照以节省磁盘。它**绝不**触碰你当前活跃的会话,也**绝不**触碰你的对话。
|
|
10
|
+
|
|
11
|
+
**手动:**即使自动清理是关闭的,你也可以自己执行清理:
|
|
12
|
+
|
|
13
|
+
- `/snapshot-auto-cleanup run [--apply]` — 先预演,再真正移除你已有一阵子没用的会话的快照。
|
|
14
|
+
- `/snapshot-auto-cleanup run --current [--apply]` — 先预演,再真正清空**当前**会话的快照。这会让它从"现在"重新开始记录回退历史(你的对话不受影响)。若此刻有正在进行中的回合,插件会先暂停它,再清空。
|
|
6
15
|
|
|
7
16
|
## 命令
|
|
8
17
|
|
|
9
18
|
```
|
|
10
|
-
/snapshot-auto-cleanup
|
|
11
|
-
/snapshot-auto-cleanup on|off
|
|
12
|
-
/snapshot-auto-cleanup max-age <天数>
|
|
13
|
-
/snapshot-auto-cleanup run
|
|
14
|
-
/snapshot-auto-cleanup run --apply
|
|
19
|
+
/snapshot-auto-cleanup 查看当前设置
|
|
20
|
+
/snapshot-auto-cleanup on|off 开启/关闭自动清理
|
|
21
|
+
/snapshot-auto-cleanup max-age <天数> 会话失活多少天后移除其快照
|
|
22
|
+
/snapshot-auto-cleanup run 预演:看自动清理会移除什么
|
|
23
|
+
/snapshot-auto-cleanup run --apply 真正移除这些快照
|
|
24
|
+
/snapshot-auto-cleanup run --current 预演:看清空本会话快照会怎样
|
|
25
|
+
/snapshot-auto-cleanup run --current --apply 真正清空本会话快照
|
|
15
26
|
```
|
|
16
27
|
|
|
17
|
-
`run`
|
|
28
|
+
`run` 一开始总是预演;加 `--apply` 才真正执行。`run` 无论自动清理是否开启都能用。
|
|
18
29
|
|
|
19
|
-
##
|
|
30
|
+
## 设置
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
设置保存在 `<dsh home>/snapshot-cleanup.json`:
|
|
22
33
|
|
|
23
34
|
```json
|
|
24
35
|
{ "enabled": false, "maxAgeDays": 30 }
|
|
25
36
|
```
|
|
26
37
|
|
|
27
38
|
- `enabled` — 是否运行自动清理(默认 `false`)。
|
|
28
|
-
- `maxAgeDays` —
|
|
39
|
+
- `maxAgeDays` — 会话失活多少天后移除其快照(默认 `30`)。只接受正数,因此一个损坏的设置永远不可能"删掉一切"。
|
|
40
|
+
|
|
41
|
+
你可以用环境变量 `DSH_SNAPSHOT_CLEANUP_CONFIG` 指向别的文件。该文件只在运行 `/snapshot-auto-cleanup` 时写入;文件缺失按安全默认值(关闭)处理。
|
|
42
|
+
|
|
43
|
+
## 自动清理何时运行
|
|
44
|
+
|
|
45
|
+
自动清理每次运行(宿主启动)**最多检查一次**,发生在首次活动时(你发出一条消息或某次工具调用完成),且仅在**已开启**且距上次检查**已满 24 小时**时才执行。这个 24 小时时钟会写入 `<dsh home>/snapshot-cleanup-last-sweep.json`,重启不会重置。它后台运行,从不阻塞你在做的事。
|
|
29
46
|
|
|
30
|
-
|
|
47
|
+
想让改动立即生效,请用 `run`;自动清理要到下次运行才会采用新设置。
|
|
31
48
|
|
|
32
|
-
##
|
|
49
|
+
## 安全
|
|
33
50
|
|
|
34
|
-
|
|
51
|
+
- 只会移除回退用的**快照**(那份文件备份)。你的对话绝不会被动到,插件也绝不改写或删除你的会话历史。
|
|
52
|
+
- 自动清理**永不**移除你**当前活跃**会话的快照——只清理已失活超过阈值的会话。
|
|
53
|
+
- `run --current` 会清空当前会话的快照。这对该会话的文件回退历史是**不可逆**的:你无法回退到清空之前的代码状态,但你的对话保持完整,会话会从"现在"开始重新记录快照。
|
|
35
54
|
|
|
36
|
-
##
|
|
55
|
+
## 已知限制
|
|
37
56
|
|
|
38
|
-
-
|
|
39
|
-
- "不活跃"以 mtime 判定:仍在写入的会话会不断把"最新成员"时间戳往后推,因此永远到不了可被清理的失活阈值。
|
|
40
|
-
- 去重 `ref` 链接是会话内相对的,所以移除整目录不会让别处产生悬空链接。
|
|
41
|
-
- 权衡:开启自动清理后,某个长时间闲置才被重新打开的会话,只能从剩余(最近 100 组)锚点回退,旧快照已不存在;对话日志永不受影响。
|
|
42
|
-
- 手动删除整个存储目录依然安全(下次捕获时自动重建);自动清理只是把这种删除限定到长期不活跃的会话。
|
|
57
|
+
插件每个会话只保留**最近 100 条消息**的快照。若你在一个很长的会话里多次回退或压缩,这 100 个名额常会被"已经无法再回退到"的消息占满,你可能无法回退到想要的位置。(Claude Code 也是同样行为。)想恢复到一个干净状态,运行 `/snapshot-auto-cleanup run --current --apply` 清空当前会话、重新开始。
|
package/lib/client.js
CHANGED
|
@@ -35,6 +35,11 @@ __export(index_exports, {
|
|
|
35
35
|
module.exports = __toCommonJS(index_exports);
|
|
36
36
|
|
|
37
37
|
// src/client/hidden.ts
|
|
38
|
+
function chatSnapshotOf(face, chatView) {
|
|
39
|
+
const legacy = face?.getSnapshot().chat;
|
|
40
|
+
if (legacy !== void 0) return legacy;
|
|
41
|
+
return chatView?.getSnapshot() ?? void 0;
|
|
42
|
+
}
|
|
38
43
|
function targetSeqOfArgs(args) {
|
|
39
44
|
if (args === void 0 || args === null) return void 0;
|
|
40
45
|
const match = args.match(/@(\d+)/);
|
|
@@ -333,10 +338,11 @@ function parseImpactList(text) {
|
|
|
333
338
|
}
|
|
334
339
|
return { restores, deletes };
|
|
335
340
|
}
|
|
336
|
-
function findCommand(
|
|
341
|
+
function findCommand(chat, match) {
|
|
342
|
+
if (chat === void 0) return void 0;
|
|
337
343
|
let found;
|
|
338
|
-
for (const key of
|
|
339
|
-
const node =
|
|
344
|
+
for (const key of chat.order) {
|
|
345
|
+
const node = chat.nodes.get(key);
|
|
340
346
|
if (node !== void 0 && node.kind === "command") {
|
|
341
347
|
const command = node.data;
|
|
342
348
|
if (match(command)) found = command;
|
|
@@ -344,11 +350,12 @@ function findCommand(snapshot, match) {
|
|
|
344
350
|
}
|
|
345
351
|
return found;
|
|
346
352
|
}
|
|
347
|
-
function knownCommandSeqs(session, match) {
|
|
353
|
+
function knownCommandSeqs(session, chatOf, match) {
|
|
348
354
|
const known = /* @__PURE__ */ new Set();
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
355
|
+
const chat = chatOf(session);
|
|
356
|
+
if (chat === void 0) return known;
|
|
357
|
+
for (const key of chat.order) {
|
|
358
|
+
const node = chat.nodes.get(key);
|
|
352
359
|
if (node !== void 0 && node.kind === "command") {
|
|
353
360
|
const command = node.data;
|
|
354
361
|
if (match(command)) known.add(command.seq);
|
|
@@ -356,7 +363,7 @@ function knownCommandSeqs(session, match) {
|
|
|
356
363
|
}
|
|
357
364
|
return known;
|
|
358
365
|
}
|
|
359
|
-
function waitForCommand(session, match, timeoutMs = 8e3) {
|
|
366
|
+
function waitForCommand(session, chatOf, match, timeoutMs = 8e3) {
|
|
360
367
|
return new Promise((resolve) => {
|
|
361
368
|
let settled = false;
|
|
362
369
|
const settle = (value) => {
|
|
@@ -367,7 +374,7 @@ function waitForCommand(session, match, timeoutMs = 8e3) {
|
|
|
367
374
|
resolve(value);
|
|
368
375
|
};
|
|
369
376
|
const check = () => {
|
|
370
|
-
const node = findCommand(session
|
|
377
|
+
const node = findCommand(chatOf(session), match);
|
|
371
378
|
if (node?.outcome !== null && node?.outcome !== void 0) {
|
|
372
379
|
settle({ kind: node.outcome.kind, text: node.outcome.text });
|
|
373
380
|
}
|
|
@@ -381,11 +388,11 @@ function isPreviewFor(node, seq) {
|
|
|
381
388
|
const args = node.args ?? "";
|
|
382
389
|
return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
|
|
383
390
|
}
|
|
384
|
-
async function previewImpact(session, seq) {
|
|
385
|
-
const known = knownCommandSeqs(session, (node) => isPreviewFor(node, seq));
|
|
391
|
+
async function previewImpact(session, chatOf, seq) {
|
|
392
|
+
const known = knownCommandSeqs(session, chatOf, (node) => isPreviewFor(node, seq));
|
|
386
393
|
const result = await session.command(`/rewind preview @${seq} both`);
|
|
387
394
|
if (!result.ok || result.value?.matched !== true) return null;
|
|
388
|
-
return waitForCommand(session, (node) => isPreviewFor(node, seq) && !known.has(node.seq));
|
|
395
|
+
return waitForCommand(session, chatOf, (node) => isPreviewFor(node, seq) && !known.has(node.seq));
|
|
389
396
|
}
|
|
390
397
|
function el(tag, className, text) {
|
|
391
398
|
const node = document.createElement(tag);
|
|
@@ -436,7 +443,7 @@ function renderImpactStep(root, opts, back, cached) {
|
|
|
436
443
|
root.replaceChildren(impact, actions);
|
|
437
444
|
focusFirst(root);
|
|
438
445
|
void (async () => {
|
|
439
|
-
const outcome = cached ?? await previewImpact(session, seq);
|
|
446
|
+
const outcome = cached ?? await previewImpact(session, opts.chatOf, seq);
|
|
440
447
|
if (outcome === null) {
|
|
441
448
|
impact.textContent = t("popover.impact.failed", { message: "preview command failed or timed out" });
|
|
442
449
|
return;
|
|
@@ -555,10 +562,10 @@ function openPopover(opts) {
|
|
|
555
562
|
openRetractPopover(opts);
|
|
556
563
|
return;
|
|
557
564
|
}
|
|
558
|
-
const { session, seq, time, preview, anchor, t } = opts;
|
|
565
|
+
const { session, seq, time, preview, anchor, t, chatOf } = opts;
|
|
559
566
|
const onRewind = opts.onRewind;
|
|
560
567
|
if (seq === void 0 || time === void 0 || onRewind === void 0) return;
|
|
561
|
-
const durableOpts = { session, seq, time, preview, anchor, t, onRewind };
|
|
568
|
+
const durableOpts = { session, seq, time, preview, anchor, t, chatOf, onRewind };
|
|
562
569
|
const root = el("div", CLASS.popover);
|
|
563
570
|
root.setAttribute("role", "dialog");
|
|
564
571
|
root.setAttribute("aria-label", t("popover.title"));
|
|
@@ -628,7 +635,7 @@ function openPopover(opts) {
|
|
|
628
635
|
popoverEl = root;
|
|
629
636
|
disposeOutside = shell.dispose;
|
|
630
637
|
void (async () => {
|
|
631
|
-
const outcome = await previewImpact(session, seq);
|
|
638
|
+
const outcome = await previewImpact(session, chatOf, seq);
|
|
632
639
|
impactOutcome = outcome;
|
|
633
640
|
if (outcome !== null && outcome.kind === "success") {
|
|
634
641
|
bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
|
|
@@ -670,10 +677,10 @@ function retractSpan(steering, targetId) {
|
|
|
670
677
|
|
|
671
678
|
// src/client/portals.tsx
|
|
672
679
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
673
|
-
function userTextAt(
|
|
674
|
-
|
|
675
|
-
for (const key of
|
|
676
|
-
const node =
|
|
680
|
+
function userTextAt(chat, seq) {
|
|
681
|
+
if (chat === void 0) return void 0;
|
|
682
|
+
for (const key of chat.order) {
|
|
683
|
+
const node = chat.nodes.get(key);
|
|
677
684
|
if (node === void 0 || node.kind !== "user") continue;
|
|
678
685
|
const user = node.data;
|
|
679
686
|
if (user.seq === seq) {
|
|
@@ -691,23 +698,23 @@ function fillComposer(text) {
|
|
|
691
698
|
textarea.focus();
|
|
692
699
|
return true;
|
|
693
700
|
}
|
|
694
|
-
function userNodeOf(
|
|
695
|
-
const node =
|
|
701
|
+
function userNodeOf(chat, key) {
|
|
702
|
+
const node = chat?.nodes.get(key);
|
|
696
703
|
if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
|
|
697
704
|
return node.data;
|
|
698
705
|
}
|
|
699
|
-
async function runRewindAndFill(session, seq, mode, currentSessionId) {
|
|
700
|
-
const known = knownCommandSeqs(session, (node) => isExecutedRewindCommand(node, seq));
|
|
706
|
+
async function runRewindAndFill(session, seq, mode, currentSessionId, chatOf) {
|
|
707
|
+
const known = knownCommandSeqs(session, chatOf, (node) => isExecutedRewindCommand(node, seq));
|
|
701
708
|
const result = await session.command(`/rewind @${seq} ${mode}`);
|
|
702
709
|
if (!result.ok || result.value?.matched !== true) return;
|
|
703
|
-
const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
|
|
710
|
+
const outcome = await waitForCommand(session, chatOf, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
|
|
704
711
|
if (outcome === null) return;
|
|
705
712
|
if (outcome.kind !== "success") {
|
|
706
713
|
showHint(outcome.text ?? "rewind failed");
|
|
707
714
|
return;
|
|
708
715
|
}
|
|
709
716
|
if (currentSessionId() !== session.sessionId) return;
|
|
710
|
-
const text = userTextAt(session, seq);
|
|
717
|
+
const text = userTextAt(chatOf(session), seq);
|
|
711
718
|
if (text === void 0 || text === "") return;
|
|
712
719
|
fillComposer(text);
|
|
713
720
|
}
|
|
@@ -794,7 +801,7 @@ function sameTargets(left, right) {
|
|
|
794
801
|
return other.kind === "pending" && target.itemId === other.itemId;
|
|
795
802
|
});
|
|
796
803
|
}
|
|
797
|
-
function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
|
|
804
|
+
function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, t, subscribeLocale }) {
|
|
798
805
|
const [targets, setTargets] = (0, import_react.useState)([]);
|
|
799
806
|
const hidden = (0, import_react.useRef)(/* @__PURE__ */ new WeakSet());
|
|
800
807
|
const [, forceRender] = (0, import_react.useReducer)((count) => count + 1, 0);
|
|
@@ -812,12 +819,12 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
812
819
|
return;
|
|
813
820
|
}
|
|
814
821
|
const snapshot = session.getSnapshot();
|
|
815
|
-
const chat =
|
|
816
|
-
const hiddenSeqs = hiddenSeqsOf(chat);
|
|
822
|
+
const chat = chatOf(session);
|
|
823
|
+
const hiddenSeqs = chat === void 0 ? /* @__PURE__ */ new Set() : hiddenSeqsOf(chat);
|
|
817
824
|
let hiddenCount = 0;
|
|
818
|
-
for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
825
|
+
for (const seat of chat === void 0 ? [] : document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
819
826
|
const key = seat.dataset.chatAnchorKey;
|
|
820
|
-
const anchor = key !== void 0 ? chat
|
|
827
|
+
const anchor = key !== void 0 ? chat?.nodes.get(key)?.anchorSeq : void 0;
|
|
821
828
|
if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
|
|
822
829
|
seat.style.display = "none";
|
|
823
830
|
seat.dataset.dshRewindHidden = "true";
|
|
@@ -834,7 +841,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
834
841
|
`[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
|
|
835
842
|
);
|
|
836
843
|
}
|
|
837
|
-
const
|
|
844
|
+
const durable = chat === void 0 ? [] : collectTargets(chat, hiddenSeqs);
|
|
845
|
+
const next = [...durable, ...collectPendingTargets(snapshot)];
|
|
838
846
|
setTargets((current) => sameTargets(current, next) ? current : next);
|
|
839
847
|
};
|
|
840
848
|
const queueRefresh = () => {
|
|
@@ -860,6 +868,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
860
868
|
target,
|
|
861
869
|
sessionId,
|
|
862
870
|
sessionOf,
|
|
871
|
+
chatOf,
|
|
863
872
|
t
|
|
864
873
|
},
|
|
865
874
|
target.key
|
|
@@ -869,6 +878,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
869
878
|
target,
|
|
870
879
|
sessionId,
|
|
871
880
|
sessionOf,
|
|
881
|
+
chatOf,
|
|
872
882
|
currentSessionId,
|
|
873
883
|
t
|
|
874
884
|
},
|
|
@@ -878,7 +888,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
878
888
|
target.key
|
|
879
889
|
));
|
|
880
890
|
}
|
|
881
|
-
function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
|
|
891
|
+
function RewindButton({ target, sessionId, sessionOf, chatOf, currentSessionId, t }) {
|
|
882
892
|
const onClick = (event) => {
|
|
883
893
|
event.stopPropagation();
|
|
884
894
|
const session = sessionOf(sessionId);
|
|
@@ -886,17 +896,18 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
|
|
|
886
896
|
console.warn("[dsh-rewind] rewind button clicked with no session binding");
|
|
887
897
|
return;
|
|
888
898
|
}
|
|
889
|
-
const node = userNodeOf(session, target.key);
|
|
899
|
+
const node = userNodeOf(chatOf(session), target.key);
|
|
890
900
|
if (node === void 0) return;
|
|
891
901
|
openPopover({
|
|
892
902
|
session,
|
|
903
|
+
chatOf,
|
|
893
904
|
seq: node.seq,
|
|
894
905
|
time: node.time,
|
|
895
906
|
preview: messagePreviewOf(node),
|
|
896
907
|
anchor: event.currentTarget,
|
|
897
908
|
t,
|
|
898
909
|
onRewind: (mode) => {
|
|
899
|
-
void runRewindAndFill(session, node.seq, mode, currentSessionId);
|
|
910
|
+
void runRewindAndFill(session, node.seq, mode, currentSessionId, chatOf);
|
|
900
911
|
}
|
|
901
912
|
});
|
|
902
913
|
};
|
|
@@ -927,13 +938,14 @@ async function retractPending(session, itemId, text) {
|
|
|
927
938
|
fillComposer(text);
|
|
928
939
|
}
|
|
929
940
|
}
|
|
930
|
-
function RetractButton({ target, sessionId, sessionOf, t }) {
|
|
941
|
+
function RetractButton({ target, sessionId, sessionOf, chatOf, t }) {
|
|
931
942
|
const onClick = (event) => {
|
|
932
943
|
event.stopPropagation();
|
|
933
944
|
const session = sessionOf(sessionId);
|
|
934
945
|
if (session === void 0) return;
|
|
935
946
|
openPopover({
|
|
936
947
|
session,
|
|
948
|
+
chatOf,
|
|
937
949
|
preview: target.preview,
|
|
938
950
|
anchor: event.currentTarget,
|
|
939
951
|
t,
|
|
@@ -986,8 +998,7 @@ var zh = {
|
|
|
986
998
|
"popover.impact.restore": "\u8FD8\u539F {path}",
|
|
987
999
|
"popover.impact.delete": "\u5220\u9664 {path}",
|
|
988
1000
|
"popover.confirm": "\u786E\u8BA4\u56DE\u9000",
|
|
989
|
-
"popover.back": "\u8FD4\u56DE"
|
|
990
|
-
"guard.hint": "/rewind \u624B\u52A8\u8F93\u5165\u4E0D\u63A5\u53D7\u53C2\u6570\uFF0C\u8BF7\u76F4\u63A5\u8F93\u5165 /rewind \u6253\u5F00\u56DE\u9000\u83DC\u5355"
|
|
1001
|
+
"popover.back": "\u8FD4\u56DE"
|
|
991
1002
|
};
|
|
992
1003
|
var en = {
|
|
993
1004
|
"button.aria": "Rewind to this message",
|
|
@@ -1013,8 +1024,7 @@ var en = {
|
|
|
1013
1024
|
"popover.impact.restore": "Restore {path}",
|
|
1014
1025
|
"popover.impact.delete": "Delete {path}",
|
|
1015
1026
|
"popover.confirm": "Confirm rewind",
|
|
1016
|
-
"popover.back": "Back"
|
|
1017
|
-
"guard.hint": "/rewind takes no typed arguments \u2014 enter /rewind to open the rewind picker."
|
|
1027
|
+
"popover.back": "Back"
|
|
1018
1028
|
};
|
|
1019
1029
|
|
|
1020
1030
|
// src/client/index.ts
|
|
@@ -1034,6 +1044,17 @@ function apply(ctx) {
|
|
|
1034
1044
|
const sessionOf = (sessionId) => ctx.sessions.binding(sessionId)?.session;
|
|
1035
1045
|
const currentSessionId = () => ctx.sessions.list.getSnapshot().current;
|
|
1036
1046
|
const subscribeLocale = (cb) => ctx.locale.subscribe(cb);
|
|
1047
|
+
const uiConversation = () => ctx.get("uiConversation");
|
|
1048
|
+
const CHAT_VIEW = "chat";
|
|
1049
|
+
const chatOf = (session) => {
|
|
1050
|
+
if (session === void 0) return void 0;
|
|
1051
|
+
try {
|
|
1052
|
+
const view = uiConversation()?.binding(session.sessionId).target(CHAT_VIEW);
|
|
1053
|
+
return chatSnapshotOf(session, view);
|
|
1054
|
+
} catch {
|
|
1055
|
+
return void 0;
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1037
1058
|
const slots = ctx.slots;
|
|
1038
1059
|
yield slots.inject(HEADER_ACTIONS_SLOT, () => slots.register(
|
|
1039
1060
|
{
|
|
@@ -1043,23 +1064,19 @@ function apply(ctx) {
|
|
|
1043
1064
|
id: "dsh-rewind-portals",
|
|
1044
1065
|
order: 1e3
|
|
1045
1066
|
},
|
|
1046
|
-
createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
|
|
1067
|
+
createRewindBridge({ sessionOf, chatOf, currentSessionId, t, subscribeLocale })
|
|
1047
1068
|
));
|
|
1048
1069
|
const commandUi = ctx.get("commandUi");
|
|
1049
|
-
const chatOf = (sessionId) => {
|
|
1050
|
-
if (sessionId === void 0) return void 0;
|
|
1051
|
-
const face = sessionOf(sessionId);
|
|
1052
|
-
return face === void 0 ? void 0 : face.getSnapshot().chat;
|
|
1053
|
-
};
|
|
1054
1070
|
const hasCandidates = (sessionId) => {
|
|
1055
|
-
const
|
|
1071
|
+
const face = sessionId === void 0 ? void 0 : sessionOf(sessionId);
|
|
1072
|
+
const chat = chatOf(face);
|
|
1056
1073
|
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
1057
1074
|
};
|
|
1058
|
-
const fetchHostCandidates = async (face) => {
|
|
1059
|
-
const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
|
|
1075
|
+
const fetchHostCandidates = async (face, chatOf2) => {
|
|
1076
|
+
const known = knownCommandSeqs(face, chatOf2, (node) => isCandidateCommand(node));
|
|
1060
1077
|
const result = await face.command("/rewind __candidates");
|
|
1061
1078
|
if (!result.ok || result.value?.matched !== true) return void 0;
|
|
1062
|
-
const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
|
|
1079
|
+
const outcome = await waitForCommand(face, chatOf2, (node) => isCandidateCommand(node) && !known.has(node.seq));
|
|
1063
1080
|
if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
|
|
1064
1081
|
return rewindCandidatesFromHostText(outcome.text);
|
|
1065
1082
|
};
|
|
@@ -1069,8 +1086,7 @@ function apply(ctx) {
|
|
|
1069
1086
|
const card = textarea?.closest("[data-composer-card]");
|
|
1070
1087
|
return card ?? textarea ?? document.body;
|
|
1071
1088
|
};
|
|
1072
|
-
|
|
1073
|
-
name: "rewind",
|
|
1089
|
+
const rewindPopupSpec = {
|
|
1074
1090
|
// The picker exists exactly while the surface has a reachable user
|
|
1075
1091
|
// message: a fresh session (no candidates) falls through to the host
|
|
1076
1092
|
// command, which fails with "no user messages" — matching the harness's
|
|
@@ -1081,7 +1097,7 @@ function apply(ctx) {
|
|
|
1081
1097
|
options: async (session) => {
|
|
1082
1098
|
const face = sessionOf(session.sessionId);
|
|
1083
1099
|
if (face === void 0) return [];
|
|
1084
|
-
const candidates = await fetchHostCandidates(face);
|
|
1100
|
+
const candidates = await fetchHostCandidates(face, chatOf);
|
|
1085
1101
|
if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
|
|
1086
1102
|
return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
|
|
1087
1103
|
},
|
|
@@ -1094,77 +1110,24 @@ function apply(ctx) {
|
|
|
1094
1110
|
if (candidate === void 0) return;
|
|
1095
1111
|
openPopover({
|
|
1096
1112
|
session: face,
|
|
1113
|
+
chatOf,
|
|
1097
1114
|
seq: candidate.seq,
|
|
1098
1115
|
time: candidate.time,
|
|
1099
1116
|
preview: candidate.preview,
|
|
1100
1117
|
anchor: composerAnchor(),
|
|
1101
1118
|
t,
|
|
1102
1119
|
onRewind: (mode) => {
|
|
1103
|
-
void runRewindAndFill(face, candidate.seq, mode, currentSessionId);
|
|
1120
|
+
void runRewindAndFill(face, candidate.seq, mode, currentSessionId, chatOf);
|
|
1104
1121
|
}
|
|
1105
1122
|
});
|
|
1106
1123
|
}
|
|
1107
1124
|
}
|
|
1108
|
-
});
|
|
1109
|
-
const PARAM_REWIND = /^\s*\/rewind\s+\S+/i;
|
|
1110
|
-
const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
|
|
1111
|
-
const hasParamRewindDraft = () => {
|
|
1112
|
-
const textarea = composerTextarea();
|
|
1113
|
-
return textarea !== null && PARAM_REWIND.test(textarea.value);
|
|
1114
|
-
};
|
|
1115
|
-
let guardHintEl = null;
|
|
1116
|
-
let guardHintTimer;
|
|
1117
|
-
const showGuardHint = () => {
|
|
1118
|
-
if (guardHintEl !== null) guardHintEl.remove();
|
|
1119
|
-
if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
|
|
1120
|
-
const textarea = composerTextarea();
|
|
1121
|
-
if (textarea === null) return;
|
|
1122
|
-
const card = textarea.closest("[data-composer-card]");
|
|
1123
|
-
const hint = document.createElement("div");
|
|
1124
|
-
hint.className = "dsh-rewind-guard-hint";
|
|
1125
|
-
hint.setAttribute("role", "status");
|
|
1126
|
-
hint.textContent = t("guard.hint");
|
|
1127
|
-
document.body.appendChild(hint);
|
|
1128
|
-
const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : textarea.getBoundingClientRect();
|
|
1129
|
-
hint.style.left = `${Math.round(rect.left)}px`;
|
|
1130
|
-
hint.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
|
|
1131
|
-
guardHintEl = hint;
|
|
1132
|
-
guardHintTimer = window.setTimeout(() => {
|
|
1133
|
-
hint.remove();
|
|
1134
|
-
if (guardHintEl === hint) guardHintEl = null;
|
|
1135
|
-
guardHintTimer = void 0;
|
|
1136
|
-
}, 3200);
|
|
1137
1125
|
};
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
showGuardHint();
|
|
1143
|
-
};
|
|
1144
|
-
const onKeyDownGuard = (event) => {
|
|
1145
|
-
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
1146
|
-
onParamRewindSubmit(event);
|
|
1147
|
-
};
|
|
1148
|
-
const onClickGuard = (event) => {
|
|
1149
|
-
if (event.button !== 0 || !hasParamRewindDraft()) return;
|
|
1150
|
-
const target = event.target;
|
|
1151
|
-
if (!(target instanceof Element)) return;
|
|
1152
|
-
const button = target.closest("button");
|
|
1153
|
-
if (button === null) return;
|
|
1154
|
-
const card = button.closest("[data-composer-card]");
|
|
1155
|
-
if (card === null) return;
|
|
1156
|
-
const all = card.querySelectorAll("button");
|
|
1157
|
-
if (all[all.length - 1] !== button) return;
|
|
1158
|
-
if (button.querySelector("rect") !== null) return;
|
|
1159
|
-
onParamRewindSubmit(event);
|
|
1160
|
-
};
|
|
1161
|
-
document.addEventListener("keydown", onKeyDownGuard, true);
|
|
1162
|
-
document.addEventListener("click", onClickGuard, true);
|
|
1126
|
+
for (const name2 of ["rewind", "undo"]) {
|
|
1127
|
+
yield commandUi.decorate({ name: name2, ...rewindPopupSpec });
|
|
1128
|
+
}
|
|
1129
|
+
const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
|
|
1163
1130
|
yield () => {
|
|
1164
|
-
document.removeEventListener("keydown", onKeyDownGuard, true);
|
|
1165
|
-
document.removeEventListener("click", onClickGuard, true);
|
|
1166
|
-
if (guardHintEl !== null) guardHintEl.remove();
|
|
1167
|
-
if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
|
|
1168
1131
|
style.remove();
|
|
1169
1132
|
};
|
|
1170
1133
|
}, "dsh-rewind client lifecycle");
|