dsh-rewind-plugin 0.6.0 → 0.6.2
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 +2 -1
- package/README.md +2 -1
- package/docs/compat/audit.md +20 -0
- package/docs/contract/client-contract.md +1 -1
- package/docs/contract/client-contract.zh.md +1 -1
- package/docs/release/release.md +8 -3
- package/docs/release/release.zh.md +4 -2
- package/lib/client.js +142 -66
- package/lib/index.js +7 -0
- package/lib/types/client/hidden.d.ts +28 -0
- package/lib/types/client/popover.d.ts +9 -2
- package/lib/types/client/portals.d.ts +52 -7
- package/lib/types/snapshot.d.ts +7 -0
- package/package.json +2 -1
package/README.en.md
CHANGED
|
@@ -11,7 +11,7 @@ Conversation rewind for DeepSeek Harness: **rewind the conversation to any earli
|
|
|
11
11
|
A deliberately focused plugin with one job: **rewind to any user message, no matter how far back, in place** — and conveniently **restore the files it changed** along the way.
|
|
12
12
|
|
|
13
13
|
- **Rewinding is time-travel** — the target message and everything after it (agent replies, tool calls) are withdrawn from the model context *and* the rendered transcript at once, with no new session and no window switch; the target's text is offered back in the composer so you can edit and re-send it — **truly seamless and convenient by design**.
|
|
14
|
-
- **Lightweight workspace backup** — Claude Code-aligned behavior:
|
|
14
|
+
- **Lightweight workspace backup** — Claude Code-aligned behavior: tracks files edited by the write-class tools, and external changes to **already-tracked** files are restorable too. Selective tracking, before-write backup, store only on change. One lightweight plugin gives you a **complete** agentic rewind capability.
|
|
15
15
|
- **Privacy-first** — the plugin never deletes or rewrites the session log (append-only) and never actually deletes any of your conversation; file restores stay inside the plugin's own backup directory. Full security model: [SECURITY.md](SECURITY.md).
|
|
16
16
|
- **A complete test system** — unit, probe, and end-to-end host verification, covering compatibility probing, log replay, resume, cross-restart and other scenarios; maintained continuously as the harness evolves to ensure feature stability.
|
|
17
17
|
|
|
@@ -152,6 +152,7 @@ withdrew should consume the stable, locale-independent helpers exported from
|
|
|
152
152
|
2. **Lightweight file rewind has a cost** — in specific cases not all changes can be rewound. Consistent with Claude Code. See: [File-rewind tracking boundary](docs/compat/tracking-boundary.md).
|
|
153
153
|
3. **Rewinds from `≤ v0.2.4`** — sessions rewound with these versions may **fail to load history** after more conversation. Install a v0.3.3-or-earlier release and use its bundled repair tool ([docs/compat/troubleshooting.md](docs/compat/troubleshooting.md)).
|
|
154
154
|
4. **Rewinds from `≤ v0.3.3`** — compaction (`/compact`) is unavailable for those sessions. Newer versions are compatible; for affected old sessions, start a new session.
|
|
155
|
+
5. **The turn-rail shows rewound turns** — the right-side rail added in DSH `v0.1.2-alpha.1` keeps ticks for withdrawn messages: clicking does not jump and hovering shows the withdrawn text. Only a display difference; no functional impact.
|
|
155
156
|
|
|
156
157
|
## Security
|
|
157
158
|
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ DeepSeek Harness 插件:**一键就地回退对话到任意更早的用户消
|
|
|
11
11
|
刻意聚焦、保持极简,只做一件事:**就地回退到任意远的用户消息**,还能**顺手还原改过的文件**。
|
|
12
12
|
|
|
13
13
|
- **回退 = 时间回溯**——目标消息及其之后的全部内容(agent 回复、工具调用)同时从**模型上下文**和**渲染对话**中撤回,不新建会话、不切换窗口;目标消息文本会回填输入框,改完可重发。**在原理上就真正无感、便捷**。
|
|
14
|
-
-
|
|
14
|
+
- **轻量工作区备份**——对齐 Claude Code:追踪写类工具编辑过的文件,**已跟踪文件的外部变更也能还原**。局部追踪、写前备份、不变不存。一个轻型插件,即拥有**完备的智能体回退能力**。
|
|
15
15
|
- **信息安全优先**——插件从不删改会话日志(append-only),从不真正删除你的任何对话;文件还原限定在插件自己的备份目录。完整安全模型:[SECURITY.md](SECURITY.md)。
|
|
16
16
|
- **完备测试系统**——单元、探针、端到端主机验证,覆盖兼容性探测、日志重放、续接、跨重启等场景;随 harness 升级持续维护,确保功能稳定。
|
|
17
17
|
|
|
@@ -140,6 +140,7 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
140
140
|
2. **轻量文件回退存在代价**——特定情况可能无法回退所有修改。行为与 Claude Code 一致。详见:[文件回退的追踪边界](docs/compat/tracking-boundary.zh.md)。
|
|
141
141
|
3. **v0.2.4 及更早版本**回退过的会话,继续对话后可能加载历史失败。可安装 v0.3.3 及之前版本的随附修复工具处理([完整步骤](docs/compat/troubleshooting.zh.md))。
|
|
142
142
|
4. **v0.3.3 及更早版本**回退过的会话,压缩对话(compact)不可用。新版本已兼容;受影响的旧会话建议新建会话。
|
|
143
|
+
5. **导轨显示已回退轮次**——DSH `v0.1.2-alpha.1` 新增的右侧导轨,会为已撤回消息保留刻度:点击不跳转、悬浮显示已撤回正文。仅显示差异,无功能影响。
|
|
143
144
|
|
|
144
145
|
## 安全
|
|
145
146
|
|
package/docs/compat/audit.md
CHANGED
|
@@ -17,6 +17,26 @@
|
|
|
17
17
|
> `0.1.2-rc.x`, `0.2.x`) requires appending a union member; rc rolling within a
|
|
18
18
|
> tuple (`0.1.1-rc.2 → rc.3`) is a no-op. Signal: `npm view @deepseek-ai/dsh version`;
|
|
19
19
|
> flow: `scripts/check-dsh-version.mjs`.
|
|
20
|
+
>
|
|
21
|
+
> Client channel (harness `0.1.2-alpha.1`, a **bundled pre-release — NOT published
|
|
22
|
+
> to npm**): the plugin also reads the session chat when a DSH Desktop build ships
|
|
23
|
+
> that version (`latest` on the registry is still `0.1.1-rc.2`; the alpha is only
|
|
24
|
+
> in the bundled `app.asar.unpacked/node_modules/@deepseek-ai/*`). There
|
|
25
|
+
> `uiConversation` exposes the chat as a per-session named `"chat"` view
|
|
26
|
+
> (contributed by `dsh-client-ui-chat` through the `uiSession` slot hook) instead
|
|
27
|
+
> of the session-face `chat` field. `src/client/hidden.ts` (`chatSnapshotOf`) reads
|
|
28
|
+
> the session-face snapshot first (rc.2 path), then the `uiConversation` `"chat"`
|
|
29
|
+
> view (alpha.1 path); both missing degrades to `undefined` (no targets, never a
|
|
30
|
+
> crash). Peers are intentionally **NOT** extended for `0.1.2-alpha.1` because it is
|
|
31
|
+
> not a registry version (see above); the OR-union stays
|
|
32
|
+
> `^0.1.0-rc.6 || ^0.1.1-rc.2` and will gain the `0.1.2` tuple when the harness
|
|
33
|
+
> publishes it. Pinned by `tests/chat-channel.test.ts` (channel precedence + the
|
|
34
|
+
> alpha.1 snapshot shape) and `tests/client-dom.test.ts` (the button-target
|
|
35
|
+
> pairing that consumes the chat). The composer refill is dual-channel the same
|
|
36
|
+
> way: on alpha.1 the withdrawn text is written through the `conversation`
|
|
37
|
+
> service's `input` resolver's `setDraft` (the harness's own Lexical editor),
|
|
38
|
+
> else the rc.2 `<textarea>` / alpha.1 `contenteditable` DOM write (`writeComposer`
|
|
39
|
+
> in `src/client/portals.tsx`); pinned by `tests/client-composer.test.ts`.
|
|
20
40
|
|
|
21
41
|
## Definition of "fully compatible" (invariants)
|
|
22
42
|
|
|
@@ -21,7 +21,7 @@ the plugin itself uses (it never reads the DOM or host copy):
|
|
|
21
21
|
```ts
|
|
22
22
|
import { hiddenSeqsOf, type HiddenChat } from 'dsh-rewind-plugin/client'
|
|
23
23
|
|
|
24
|
-
const chat = session.getSnapshot().chat
|
|
24
|
+
const chat = session.getSnapshot().chat // rc.2 source; 0.1.2-alpha.1+ uses the uiConversation "chat" view
|
|
25
25
|
const hidden = hiddenSeqsOf(chat as HiddenChat) // Set<number> of anchor seqs
|
|
26
26
|
```
|
|
27
27
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
```ts
|
|
21
21
|
import { hiddenSeqsOf, type HiddenChat } from 'dsh-rewind-plugin/client'
|
|
22
22
|
|
|
23
|
-
const chat = session.getSnapshot().chat
|
|
23
|
+
const chat = session.getSnapshot().chat // rc.2 来源;0.1.2-alpha.1+ 用 uiConversation "chat" 视图
|
|
24
24
|
const hidden = hiddenSeqsOf(chat as HiddenChat) // 被隐藏的 anchor seq 集合
|
|
25
25
|
```
|
|
26
26
|
|
package/docs/release/release.md
CHANGED
|
@@ -38,6 +38,9 @@ npm version patch
|
|
|
38
38
|
git push origin main --tags # triggers .github/workflows/publish.yml
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
- **Before bumping, manually confirm there is no newer DSH version the plugin
|
|
42
|
+
has not been verified against** (a pre-release can ship in DSH Desktop
|
|
43
|
+
without being on npm, e.g. `0.1.2-alpha.1`; see docs/compat/audit.md).
|
|
41
44
|
- The workflow verifies the tag matches `package.json`, runs typecheck + tests +
|
|
42
45
|
a full build + artifact verification, publishes with `--provenance`
|
|
43
46
|
(Sigstore), and creates a GitHub Release. It is **idempotent** — an already
|
|
@@ -58,9 +61,11 @@ uses an OR-union covering every published rc tuple series
|
|
|
58
61
|
(`0.1.1 → 0.1.2 → 0.2.x`); rc rolling within a tuple (`0.1.1-rc.2 → rc.3`)
|
|
59
62
|
needs nothing. All `@deepseek-ai/*` packages release together;
|
|
60
63
|
`npm view @deepseek-ai/dsh version` is the authoritative signal.
|
|
61
|
-
- **
|
|
62
|
-
latest npm version against the tuples the peers cover
|
|
63
|
-
|
|
64
|
+
- **Published-tuple check (optional)**: `node scripts/check-dsh-version.mjs`
|
|
65
|
+
compares the latest npm version against the tuples the peers cover (exit 0 =
|
|
66
|
+
nothing to do, exit 1 = update). It reads npm published versions only; a
|
|
67
|
+
newer-but-unpublished pre-release is a manual pre-release check — see the
|
|
68
|
+
"Before bumping" step above.
|
|
64
69
|
- **Update steps**: append `|| ^<new-tuple>-rc.<n>` to every
|
|
65
70
|
`@deepseek-ai/dsh-*` peer → bump devDependencies to the latest → `npm
|
|
66
71
|
install` → `npm run check` → release.
|
|
@@ -37,6 +37,7 @@ npm version patch
|
|
|
37
37
|
git push origin main --tags # 触发 .github/workflows/publish.yml
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
- **升版前手动确认**:确认无插件未针对其验证过的更新 DSH 版本(pre-release 可能只随 Desktop 捆绑、而不发到 npm,如 `0.1.2-alpha.1`;见 docs/compat/audit.md)。
|
|
40
41
|
- workflow 校验 tag 与 `package.json` 版本一致,跑 typecheck + 测试 + 完整
|
|
41
42
|
构建 + 产物验证,以 `--provenance`(Sigstore)发布并创建 GitHub Release。
|
|
42
43
|
**幂等**——已发布的版本会跳过。
|
|
@@ -54,8 +55,9 @@ OR 并集覆盖 DSH 已发布的每个 rc 元组系列(如 `^0.1.0-rc.6 || ^0.
|
|
|
54
55
|
- **何时需要更新**:仅当 DSH 发布新元组(`0.1.1 → 0.1.2 → 0.2.x`)时;
|
|
55
56
|
同元组内 rc 滚动(`0.1.1-rc.2 → rc.3`)无需动作。DSH 所有包同版本发布,
|
|
56
57
|
`npm view @deepseek-ai/dsh version` 即权威信号。
|
|
57
|
-
-
|
|
58
|
-
peer
|
|
58
|
+
- **已发布元组检查(可选)**:`node scripts/check-dsh-version.mjs` 用 npm 最新版本
|
|
59
|
+
对比 peer 覆盖的元组(exit 0 无需动作,exit 1 需要)。它**只读 npm 已发布版本**;
|
|
60
|
+
更新的未发布 pre-release 走**手动发布前检查**——见上文"升版前手动确认"。
|
|
59
61
|
- **更新步骤**:给每个 `@deepseek-ai/dsh-*` peer 追加 `|| ^<新元组>-rc.<n>`
|
|
60
62
|
→ devDependencies 同步升到最新 → `npm install` → `npm run check` → 发版。
|
|
61
63
|
- **正式版后收敛**:DSH 发布 final 版本后,正式版不受 prerelease 元组规则
|
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) {
|
|
@@ -682,8 +689,8 @@ function userTextAt(session, seq) {
|
|
|
682
689
|
}
|
|
683
690
|
return void 0;
|
|
684
691
|
}
|
|
685
|
-
function
|
|
686
|
-
const textarea = document.querySelector(
|
|
692
|
+
function fillComposerTextarea(text) {
|
|
693
|
+
const textarea = document.querySelector(COMPOSER_TEXTAREA_SELECTOR);
|
|
687
694
|
if (textarea === null) return false;
|
|
688
695
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
|
689
696
|
setter?.call(textarea, text);
|
|
@@ -691,42 +698,82 @@ function fillComposer(text) {
|
|
|
691
698
|
textarea.focus();
|
|
692
699
|
return true;
|
|
693
700
|
}
|
|
694
|
-
function
|
|
695
|
-
const
|
|
701
|
+
function fillComposerEditable(text) {
|
|
702
|
+
const editable = document.querySelector(COMPOSER_EDITABLE_SELECTOR);
|
|
703
|
+
if (editable === null) return false;
|
|
704
|
+
editable.focus();
|
|
705
|
+
const selection = document.getSelection();
|
|
706
|
+
if (selection !== null) {
|
|
707
|
+
const range = document.createRange();
|
|
708
|
+
range.selectNodeContents(editable);
|
|
709
|
+
selection.removeAllRanges();
|
|
710
|
+
selection.addRange(range);
|
|
711
|
+
}
|
|
712
|
+
let ok = false;
|
|
713
|
+
try {
|
|
714
|
+
ok = document.execCommand("insertText", false, text);
|
|
715
|
+
} catch {
|
|
716
|
+
ok = false;
|
|
717
|
+
}
|
|
718
|
+
if (ok) return true;
|
|
719
|
+
editable.textContent = text;
|
|
720
|
+
editable.dispatchEvent(new Event("input", { bubbles: true }));
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
function fillComposer(text) {
|
|
724
|
+
if (fillComposerTextarea(text)) return true;
|
|
725
|
+
return fillComposerEditable(text);
|
|
726
|
+
}
|
|
727
|
+
function writeComposer(text, facade) {
|
|
728
|
+
if (facade !== void 0) {
|
|
729
|
+
try {
|
|
730
|
+
facade.setDraft(text);
|
|
731
|
+
return true;
|
|
732
|
+
} catch {
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return fillComposer(text);
|
|
736
|
+
}
|
|
737
|
+
function userNodeOf(chat, key) {
|
|
738
|
+
const node = chat?.nodes.get(key);
|
|
696
739
|
if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
|
|
697
740
|
return node.data;
|
|
698
741
|
}
|
|
699
|
-
async function runRewindAndFill(session, seq, mode, currentSessionId) {
|
|
700
|
-
const known = knownCommandSeqs(session, (node) => isExecutedRewindCommand(node, seq));
|
|
742
|
+
async function runRewindAndFill(session, seq, mode, currentSessionId, chatOf, setComposerText) {
|
|
743
|
+
const known = knownCommandSeqs(session, chatOf, (node) => isExecutedRewindCommand(node, seq));
|
|
701
744
|
const result = await session.command(`/rewind @${seq} ${mode}`);
|
|
702
745
|
if (!result.ok || result.value?.matched !== true) return;
|
|
703
|
-
const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
|
|
746
|
+
const outcome = await waitForCommand(session, chatOf, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
|
|
704
747
|
if (outcome === null) return;
|
|
705
748
|
if (outcome.kind !== "success") {
|
|
706
749
|
showHint(outcome.text ?? "rewind failed");
|
|
707
750
|
return;
|
|
708
751
|
}
|
|
709
752
|
if (currentSessionId() !== session.sessionId) return;
|
|
710
|
-
const text = userTextAt(session, seq);
|
|
753
|
+
const text = userTextAt(chatOf(session), seq);
|
|
711
754
|
if (text === void 0 || text === "") return;
|
|
712
|
-
|
|
755
|
+
setComposerText(session.sessionId, text);
|
|
756
|
+
}
|
|
757
|
+
function composerSurface() {
|
|
758
|
+
return document.querySelector(COMPOSER_TEXTAREA_SELECTOR) ?? document.querySelector(COMPOSER_EDITABLE_SELECTOR);
|
|
713
759
|
}
|
|
714
760
|
function showHint(text) {
|
|
715
|
-
const
|
|
761
|
+
const surface = composerSurface();
|
|
716
762
|
const hint = document.createElement("div");
|
|
717
763
|
hint.className = CLASS.guardHint;
|
|
718
764
|
hint.setAttribute("role", "status");
|
|
719
765
|
hint.textContent = text;
|
|
720
766
|
document.body.appendChild(hint);
|
|
721
|
-
if (
|
|
722
|
-
const card =
|
|
723
|
-
const rect = card instanceof HTMLElement ? card.getBoundingClientRect() :
|
|
767
|
+
if (surface !== null) {
|
|
768
|
+
const card = surface.closest("[data-composer-card]");
|
|
769
|
+
const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : surface.getBoundingClientRect();
|
|
724
770
|
hint.style.left = `${Math.round(rect.left)}px`;
|
|
725
771
|
hint.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
|
|
726
772
|
}
|
|
727
773
|
window.setTimeout(() => hint.remove(), 3200);
|
|
728
774
|
}
|
|
729
|
-
var
|
|
775
|
+
var COMPOSER_TEXTAREA_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
776
|
+
var COMPOSER_EDITABLE_SELECTOR = "[data-composer-input]";
|
|
730
777
|
var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
|
|
731
778
|
var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
|
|
732
779
|
var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
|
|
@@ -794,7 +841,7 @@ function sameTargets(left, right) {
|
|
|
794
841
|
return other.kind === "pending" && target.itemId === other.itemId;
|
|
795
842
|
});
|
|
796
843
|
}
|
|
797
|
-
function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
|
|
844
|
+
function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, t, subscribeLocale, setComposerText }) {
|
|
798
845
|
const [targets, setTargets] = (0, import_react.useState)([]);
|
|
799
846
|
const hidden = (0, import_react.useRef)(/* @__PURE__ */ new WeakSet());
|
|
800
847
|
const [, forceRender] = (0, import_react.useReducer)((count) => count + 1, 0);
|
|
@@ -812,12 +859,12 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
812
859
|
return;
|
|
813
860
|
}
|
|
814
861
|
const snapshot = session.getSnapshot();
|
|
815
|
-
const chat =
|
|
816
|
-
const hiddenSeqs = hiddenSeqsOf(chat);
|
|
862
|
+
const chat = chatOf(session);
|
|
863
|
+
const hiddenSeqs = chat === void 0 ? /* @__PURE__ */ new Set() : hiddenSeqsOf(chat);
|
|
817
864
|
let hiddenCount = 0;
|
|
818
|
-
for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
865
|
+
for (const seat of chat === void 0 ? [] : document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
819
866
|
const key = seat.dataset.chatAnchorKey;
|
|
820
|
-
const anchor = key !== void 0 ? chat
|
|
867
|
+
const anchor = key !== void 0 ? chat?.nodes.get(key)?.anchorSeq : void 0;
|
|
821
868
|
if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
|
|
822
869
|
seat.style.display = "none";
|
|
823
870
|
seat.dataset.dshRewindHidden = "true";
|
|
@@ -834,7 +881,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
834
881
|
`[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
|
|
835
882
|
);
|
|
836
883
|
}
|
|
837
|
-
const
|
|
884
|
+
const durable = chat === void 0 ? [] : collectTargets(chat, hiddenSeqs);
|
|
885
|
+
const next = [...durable, ...collectPendingTargets(snapshot)];
|
|
838
886
|
setTargets((current) => sameTargets(current, next) ? current : next);
|
|
839
887
|
};
|
|
840
888
|
const queueRefresh = () => {
|
|
@@ -860,6 +908,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
860
908
|
target,
|
|
861
909
|
sessionId,
|
|
862
910
|
sessionOf,
|
|
911
|
+
chatOf,
|
|
912
|
+
setComposerText,
|
|
863
913
|
t
|
|
864
914
|
},
|
|
865
915
|
target.key
|
|
@@ -869,7 +919,9 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
869
919
|
target,
|
|
870
920
|
sessionId,
|
|
871
921
|
sessionOf,
|
|
922
|
+
chatOf,
|
|
872
923
|
currentSessionId,
|
|
924
|
+
setComposerText,
|
|
873
925
|
t
|
|
874
926
|
},
|
|
875
927
|
target.key
|
|
@@ -878,7 +930,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
878
930
|
target.key
|
|
879
931
|
));
|
|
880
932
|
}
|
|
881
|
-
function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
|
|
933
|
+
function RewindButton({ target, sessionId, sessionOf, chatOf, currentSessionId, setComposerText, t }) {
|
|
882
934
|
const onClick = (event) => {
|
|
883
935
|
event.stopPropagation();
|
|
884
936
|
const session = sessionOf(sessionId);
|
|
@@ -886,17 +938,18 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
|
|
|
886
938
|
console.warn("[dsh-rewind] rewind button clicked with no session binding");
|
|
887
939
|
return;
|
|
888
940
|
}
|
|
889
|
-
const node = userNodeOf(session, target.key);
|
|
941
|
+
const node = userNodeOf(chatOf(session), target.key);
|
|
890
942
|
if (node === void 0) return;
|
|
891
943
|
openPopover({
|
|
892
944
|
session,
|
|
945
|
+
chatOf,
|
|
893
946
|
seq: node.seq,
|
|
894
947
|
time: node.time,
|
|
895
948
|
preview: messagePreviewOf(node),
|
|
896
949
|
anchor: event.currentTarget,
|
|
897
950
|
t,
|
|
898
951
|
onRewind: (mode) => {
|
|
899
|
-
void runRewindAndFill(session, node.seq, mode, currentSessionId);
|
|
952
|
+
void runRewindAndFill(session, node.seq, mode, currentSessionId, chatOf, setComposerText);
|
|
900
953
|
}
|
|
901
954
|
});
|
|
902
955
|
};
|
|
@@ -913,10 +966,12 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
|
|
|
913
966
|
);
|
|
914
967
|
}
|
|
915
968
|
function composerText() {
|
|
916
|
-
const
|
|
917
|
-
|
|
969
|
+
const surface = composerSurface();
|
|
970
|
+
if (surface === null) return "";
|
|
971
|
+
if (surface instanceof HTMLTextAreaElement) return surface.value;
|
|
972
|
+
return surface.textContent ?? "";
|
|
918
973
|
}
|
|
919
|
-
async function retractPending(session, itemId, text) {
|
|
974
|
+
async function retractPending(session, itemId, text, setComposerText) {
|
|
920
975
|
await session.cancel();
|
|
921
976
|
const queue = session.getSnapshot().queue;
|
|
922
977
|
const steering = queue.filter((item) => item.placement === "steering");
|
|
@@ -924,22 +979,23 @@ async function retractPending(session, itemId, text) {
|
|
|
924
979
|
await session.updateQueue(id, { kind: "remove" });
|
|
925
980
|
}
|
|
926
981
|
if (text !== null && text !== "" && composerText().trim() === "") {
|
|
927
|
-
|
|
982
|
+
setComposerText(session.sessionId, text);
|
|
928
983
|
}
|
|
929
984
|
}
|
|
930
|
-
function RetractButton({ target, sessionId, sessionOf, t }) {
|
|
985
|
+
function RetractButton({ target, sessionId, sessionOf, chatOf, setComposerText, t }) {
|
|
931
986
|
const onClick = (event) => {
|
|
932
987
|
event.stopPropagation();
|
|
933
988
|
const session = sessionOf(sessionId);
|
|
934
989
|
if (session === void 0) return;
|
|
935
990
|
openPopover({
|
|
936
991
|
session,
|
|
992
|
+
chatOf,
|
|
937
993
|
preview: target.preview,
|
|
938
994
|
anchor: event.currentTarget,
|
|
939
995
|
t,
|
|
940
996
|
retract: { itemId: target.itemId, text: target.text },
|
|
941
997
|
onRetract: () => {
|
|
942
|
-
void retractPending(session, target.itemId, target.text);
|
|
998
|
+
void retractPending(session, target.itemId, target.text, setComposerText);
|
|
943
999
|
}
|
|
944
1000
|
});
|
|
945
1001
|
};
|
|
@@ -1020,7 +1076,8 @@ var name = "dsh-rewind";
|
|
|
1020
1076
|
var inject = ["slots", "sessions", "locale", "commandUi"];
|
|
1021
1077
|
var NS = "rewind";
|
|
1022
1078
|
var HEADER_ACTIONS_SLOT = "conversation.session.header.actions";
|
|
1023
|
-
var
|
|
1079
|
+
var COMPOSER_TEXTAREA_SELECTOR2 = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
1080
|
+
var COMPOSER_EDITABLE_SELECTOR2 = "[data-composer-input]";
|
|
1024
1081
|
function apply(ctx) {
|
|
1025
1082
|
ctx.effect(function* () {
|
|
1026
1083
|
yield ctx.locale.register(NS, { zh, en });
|
|
@@ -1032,6 +1089,28 @@ function apply(ctx) {
|
|
|
1032
1089
|
const sessionOf = (sessionId) => ctx.sessions.binding(sessionId)?.session;
|
|
1033
1090
|
const currentSessionId = () => ctx.sessions.list.getSnapshot().current;
|
|
1034
1091
|
const subscribeLocale = (cb) => ctx.locale.subscribe(cb);
|
|
1092
|
+
const uiConversation = () => ctx.get("uiConversation");
|
|
1093
|
+
const CHAT_VIEW = "chat";
|
|
1094
|
+
const chatOf = (session) => {
|
|
1095
|
+
if (session === void 0) return void 0;
|
|
1096
|
+
try {
|
|
1097
|
+
const view = uiConversation()?.binding(session.sessionId).target(CHAT_VIEW);
|
|
1098
|
+
return chatSnapshotOf(session, view);
|
|
1099
|
+
} catch {
|
|
1100
|
+
return void 0;
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
const setComposerText = (sessionId, text) => {
|
|
1104
|
+
const conversation = ctx.get("conversation");
|
|
1105
|
+
const input = conversation?.input;
|
|
1106
|
+
const scope = ctx.sessions.scope?.(sessionId);
|
|
1107
|
+
return writeComposer(
|
|
1108
|
+
text,
|
|
1109
|
+
input !== void 0 && scope !== void 0 ? { setDraft: (draft) => {
|
|
1110
|
+
input.for(scope).setDraft(draft);
|
|
1111
|
+
} } : void 0
|
|
1112
|
+
);
|
|
1113
|
+
};
|
|
1035
1114
|
const slots = ctx.slots;
|
|
1036
1115
|
yield slots.inject(HEADER_ACTIONS_SLOT, () => slots.register(
|
|
1037
1116
|
{
|
|
@@ -1041,31 +1120,27 @@ function apply(ctx) {
|
|
|
1041
1120
|
id: "dsh-rewind-portals",
|
|
1042
1121
|
order: 1e3
|
|
1043
1122
|
},
|
|
1044
|
-
createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
|
|
1123
|
+
createRewindBridge({ sessionOf, chatOf, currentSessionId, setComposerText, t, subscribeLocale })
|
|
1045
1124
|
));
|
|
1046
1125
|
const commandUi = ctx.get("commandUi");
|
|
1047
|
-
const chatOf = (sessionId) => {
|
|
1048
|
-
if (sessionId === void 0) return void 0;
|
|
1049
|
-
const face = sessionOf(sessionId);
|
|
1050
|
-
return face === void 0 ? void 0 : face.getSnapshot().chat;
|
|
1051
|
-
};
|
|
1052
1126
|
const hasCandidates = (sessionId) => {
|
|
1053
|
-
const
|
|
1127
|
+
const face = sessionId === void 0 ? void 0 : sessionOf(sessionId);
|
|
1128
|
+
const chat = chatOf(face);
|
|
1054
1129
|
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
1055
1130
|
};
|
|
1056
|
-
const fetchHostCandidates = async (face) => {
|
|
1057
|
-
const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
|
|
1131
|
+
const fetchHostCandidates = async (face, chatOf2) => {
|
|
1132
|
+
const known = knownCommandSeqs(face, chatOf2, (node) => isCandidateCommand(node));
|
|
1058
1133
|
const result = await face.command("/rewind __candidates");
|
|
1059
1134
|
if (!result.ok || result.value?.matched !== true) return void 0;
|
|
1060
|
-
const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
|
|
1135
|
+
const outcome = await waitForCommand(face, chatOf2, (node) => isCandidateCommand(node) && !known.has(node.seq));
|
|
1061
1136
|
if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
|
|
1062
1137
|
return rewindCandidatesFromHostText(outcome.text);
|
|
1063
1138
|
};
|
|
1064
1139
|
const hostCandidatesCache = /* @__PURE__ */ new Map();
|
|
1065
1140
|
const composerAnchor = () => {
|
|
1066
|
-
const
|
|
1067
|
-
const card =
|
|
1068
|
-
return card ??
|
|
1141
|
+
const surface = composerSurface2();
|
|
1142
|
+
const card = surface?.closest("[data-composer-card]");
|
|
1143
|
+
return card ?? surface ?? document.body;
|
|
1069
1144
|
};
|
|
1070
1145
|
const rewindPopupSpec = {
|
|
1071
1146
|
// The picker exists exactly while the surface has a reachable user
|
|
@@ -1078,7 +1153,7 @@ function apply(ctx) {
|
|
|
1078
1153
|
options: async (session) => {
|
|
1079
1154
|
const face = sessionOf(session.sessionId);
|
|
1080
1155
|
if (face === void 0) return [];
|
|
1081
|
-
const candidates = await fetchHostCandidates(face);
|
|
1156
|
+
const candidates = await fetchHostCandidates(face, chatOf);
|
|
1082
1157
|
if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
|
|
1083
1158
|
return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
|
|
1084
1159
|
},
|
|
@@ -1091,13 +1166,14 @@ function apply(ctx) {
|
|
|
1091
1166
|
if (candidate === void 0) return;
|
|
1092
1167
|
openPopover({
|
|
1093
1168
|
session: face,
|
|
1169
|
+
chatOf,
|
|
1094
1170
|
seq: candidate.seq,
|
|
1095
1171
|
time: candidate.time,
|
|
1096
1172
|
preview: candidate.preview,
|
|
1097
1173
|
anchor: composerAnchor(),
|
|
1098
1174
|
t,
|
|
1099
1175
|
onRewind: (mode) => {
|
|
1100
|
-
void runRewindAndFill(face, candidate.seq, mode, currentSessionId);
|
|
1176
|
+
void runRewindAndFill(face, candidate.seq, mode, currentSessionId, chatOf, setComposerText);
|
|
1101
1177
|
}
|
|
1102
1178
|
});
|
|
1103
1179
|
}
|
|
@@ -1106,7 +1182,7 @@ function apply(ctx) {
|
|
|
1106
1182
|
for (const name2 of ["rewind", "undo"]) {
|
|
1107
1183
|
yield commandUi.decorate({ name: name2, ...rewindPopupSpec });
|
|
1108
1184
|
}
|
|
1109
|
-
const
|
|
1185
|
+
const composerSurface2 = () => document.querySelector(COMPOSER_TEXTAREA_SELECTOR2) ?? document.querySelector(COMPOSER_EDITABLE_SELECTOR2);
|
|
1110
1186
|
yield () => {
|
|
1111
1187
|
style.remove();
|
|
1112
1188
|
};
|
package/lib/index.js
CHANGED
|
@@ -772,6 +772,13 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
772
772
|
* already absent, i.e. the target state is reached), a restore is a plain
|
|
773
773
|
* writeFile with a recursive mkdir of the parent. Returns how the outcome
|
|
774
774
|
* should record it.
|
|
775
|
+
*
|
|
776
|
+
* This is the only place the store writes restored content to the real FS,
|
|
777
|
+
* and it is deliberately a raw `writeFile`/`unlink` rather than the fs
|
|
778
|
+
* service: the caller only ever hands it a path from `planRestore` — one the
|
|
779
|
+
* session's own write-class tool call recorded and resolved (never a
|
|
780
|
+
* symlink/hard link) and only when it differs from the live disk. So no
|
|
781
|
+
* arbitrary path, no model input, never automatic.
|
|
775
782
|
*/
|
|
776
783
|
async applyActionToDisk(kind, path, content, deleteFile) {
|
|
777
784
|
if (kind === "delete") {
|
|
@@ -13,6 +13,34 @@ export interface HiddenChat {
|
|
|
13
13
|
get(key: string): ChatConversationViewNode | undefined;
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Reader for one session's live chat snapshot. The dual channel hides the
|
|
18
|
+
* harness split behind SiriLee/dsh-rewind#7: rc.2 serves the chat from the
|
|
19
|
+
* session face snapshot, while 0.1.2-alpha.1+ serves it from the
|
|
20
|
+
* `uiConversation` service's named "chat" view (contributed by
|
|
21
|
+
* dsh-client-ui-chat through the uiSession slot hook).
|
|
22
|
+
*/
|
|
23
|
+
export type ChatOf = (session: {
|
|
24
|
+
readonly sessionId: string;
|
|
25
|
+
getSnapshot(): {
|
|
26
|
+
chat?: unknown;
|
|
27
|
+
};
|
|
28
|
+
} | undefined) => HiddenChat | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the chat snapshot across the two harness channels: the session-face
|
|
31
|
+
* snapshot first (rc.2 — on alpha.1+ the face no longer carries `chat`, so the
|
|
32
|
+
* field reads `undefined`), then the `uiConversation` "chat" view. The view's
|
|
33
|
+
* `getSnapshot()` returns undefined until the named view is registered, so
|
|
34
|
+
* both channels missing degrades to `undefined` (no targets, no hiding —
|
|
35
|
+
* never a crash).
|
|
36
|
+
*/
|
|
37
|
+
export declare function chatSnapshotOf(face: {
|
|
38
|
+
getSnapshot(): {
|
|
39
|
+
chat?: unknown;
|
|
40
|
+
};
|
|
41
|
+
} | undefined, chatView: {
|
|
42
|
+
getSnapshot(): unknown;
|
|
43
|
+
} | undefined): HiddenChat | undefined;
|
|
16
44
|
/**
|
|
17
45
|
* Extract the rewind target seq from a `/rewind` command's structured `args`
|
|
18
46
|
* (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
|
|
17
17
|
import type { CommandNode } from '@deepseek-ai/dsh-client-runtime/client';
|
|
18
|
+
import { type ChatOf } from './hidden.ts';
|
|
18
19
|
import type { RewindKey } from './locales.ts';
|
|
19
20
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
20
21
|
export interface PopoverOptions {
|
|
@@ -31,6 +32,12 @@ export interface PopoverOptions {
|
|
|
31
32
|
/** Pending variant: executed after the retract confirm closes the popover. */
|
|
32
33
|
readonly onRetract?: () => void;
|
|
33
34
|
readonly preview: string;
|
|
35
|
+
/**
|
|
36
|
+
* Dual-channel chat reader (rc.2 session face / alpha.1+ uiConversation
|
|
37
|
+
* view): the durable variant's command probes scan the chat through it.
|
|
38
|
+
* Unused by the pending-retract variant.
|
|
39
|
+
*/
|
|
40
|
+
readonly chatOf: ChatOf;
|
|
34
41
|
/** The button that opened the popover (outside-click ignore target). */
|
|
35
42
|
readonly anchor: HTMLElement;
|
|
36
43
|
readonly t: Translate;
|
|
@@ -50,13 +57,13 @@ export declare function closePopover(): void;
|
|
|
50
57
|
* command's stale outcome (e.g. an older preview that found file changes,
|
|
51
58
|
* after those changes were already restored).
|
|
52
59
|
*/
|
|
53
|
-
export declare function knownCommandSeqs(session: SessionFace, match: (node: CommandNode) => boolean): Set<number>;
|
|
60
|
+
export declare function knownCommandSeqs(session: SessionFace, chatOf: ChatOf, match: (node: CommandNode) => boolean): Set<number>;
|
|
54
61
|
/**
|
|
55
62
|
* Resolve the outcome of the newest matching rewind command by watching the
|
|
56
63
|
* session snapshot (command/run + command/done land as one CommandNode).
|
|
57
64
|
* @returns the outcome text-bearing node, or null on timeout.
|
|
58
65
|
*/
|
|
59
|
-
export declare function waitForCommand(session: SessionFace, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
|
|
66
|
+
export declare function waitForCommand(session: SessionFace, chatOf: ChatOf, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
|
|
60
67
|
kind: 'success' | 'error';
|
|
61
68
|
text?: string;
|
|
62
69
|
} | null>;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { type ReactNode } from 'react';
|
|
26
26
|
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
|
|
27
|
+
import { type ChatOf, type HiddenChat } from './hidden.ts';
|
|
27
28
|
import type { RewindKey } from './locales.ts';
|
|
28
29
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
29
30
|
/** One portal target: the actions row of a user/steering seat + its durable node. */
|
|
@@ -51,9 +52,22 @@ export type PortalTarget = {
|
|
|
51
52
|
/** Capabilities the session-scoped bridge receives from the plugin apply(). */
|
|
52
53
|
export interface RewindBridgeDeps {
|
|
53
54
|
readonly sessionOf: (sessionId: string) => SessionFace | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Dual-channel chat reader (rc.2 session face / alpha.1+ uiConversation
|
|
57
|
+
* "chat" view): every chat snapshot read goes through it. See
|
|
58
|
+
* `chatSnapshotOf` in hidden.ts for the channel precedence.
|
|
59
|
+
*/
|
|
60
|
+
readonly chatOf: ChatOf;
|
|
54
61
|
readonly currentSessionId: () => string | undefined;
|
|
55
62
|
readonly t: Translate;
|
|
56
63
|
readonly subscribeLocale: (cb: () => void) => () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Session-aware dual-channel composer writer (see `writeComposer`): the
|
|
66
|
+
* alpha.1+ `conversation.input` facade `setDraft` when reachable, else the
|
|
67
|
+
* rc.2/alpha.1 DOM fill. Session-scoped so the refill only lands in the
|
|
68
|
+
* session that just rewound.
|
|
69
|
+
*/
|
|
70
|
+
readonly setComposerText: (sessionId: string, text: string) => boolean;
|
|
57
71
|
}
|
|
58
72
|
/** Structural face of the runtime slot service (see the module doc). */
|
|
59
73
|
export interface SlotsLike {
|
|
@@ -67,13 +81,34 @@ export interface SlotsLike {
|
|
|
67
81
|
}) => ReactNode): () => void;
|
|
68
82
|
}
|
|
69
83
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
84
|
+
* The alpha.1+ session input facade's write face (structural, so the plugin
|
|
85
|
+
* never imports the conversation UI package). `setDraft` replaces the whole
|
|
86
|
+
* composer draft through the harness's own Lexical editor — the correct way
|
|
87
|
+
* to restore the withdrawn text.
|
|
88
|
+
*/
|
|
89
|
+
interface ComposerDraftWriter {
|
|
90
|
+
setDraft(text: string): void;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fill the dsh composer with `text`. DOM dual-channel fallback: the rc.2
|
|
94
|
+
* `<textarea>` path, then the alpha.1+ `contenteditable` path. Used by
|
|
95
|
+
* `setComposerText` (the harness-facade-aware writer) as the last-resort and
|
|
96
|
+
* by `runRewindAndFill` to put the withdrawn target message back into the
|
|
97
|
+
* composer after a rewind. Best-effort — no composer match means false,
|
|
98
|
+
* never a throw.
|
|
75
99
|
*/
|
|
76
100
|
export declare function fillComposer(text: string): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Dual-channel composer write, mirroring `chatSnapshotOf`: prefer the harness
|
|
103
|
+
* facade's `setDraft` (alpha.1+, correct whole-draft replace), then degrade
|
|
104
|
+
* to the DOM `fillComposer` (rc.2 textarea / alpha.1 contenteditable). A
|
|
105
|
+
* facade that throws (session teardown) is treated as absent so the DOM path
|
|
106
|
+
* still restores the text. Never throws.
|
|
107
|
+
* @param text - the withdrawn target message text.
|
|
108
|
+
* @param facade - the alpha.1+ session input draft writer, when reachable.
|
|
109
|
+
* @returns whether a channel applied the text.
|
|
110
|
+
*/
|
|
111
|
+
export declare function writeComposer(text: string, facade: ComposerDraftWriter | undefined): boolean;
|
|
77
112
|
/**
|
|
78
113
|
* Execute one rewind from the popover and, when it settles successfully,
|
|
79
114
|
* put the withdrawn target message's text back into the composer so the
|
|
@@ -87,7 +122,13 @@ export declare function fillComposer(text: string): boolean;
|
|
|
87
122
|
* the old baseline heuristic refilled withdrawn text into the composer
|
|
88
123
|
* after switching sessions or restarting dsh.
|
|
89
124
|
*/
|
|
90
|
-
export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined): Promise<void>;
|
|
125
|
+
export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined, chatOf: ChatOf, setComposerText: (sessionId: string, text: string) => boolean): Promise<void>;
|
|
126
|
+
/**
|
|
127
|
+
* Collect the portal targets of one session: user rows × snapshot nodes.
|
|
128
|
+
* Exported as a test seam — the DOM→targets pairing that drives the ↶ button
|
|
129
|
+
* is otherwise only reachable through a full React portal render.
|
|
130
|
+
*/
|
|
131
|
+
export declare function collectTargets(chat: HiddenChat, hiddenSeqs: ReadonlySet<number>): readonly PortalTarget[];
|
|
91
132
|
interface RewindPortalsProps extends RewindBridgeDeps {
|
|
92
133
|
readonly sessionId: string;
|
|
93
134
|
}
|
|
@@ -98,7 +139,11 @@ interface RewindPortalsProps extends RewindBridgeDeps {
|
|
|
98
139
|
* skipped when the target set is unchanged), so the plugin never runs a
|
|
99
140
|
* synchronous full-transcript scan inside a commit microtask.
|
|
100
141
|
*/
|
|
101
|
-
export declare function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }: RewindPortalsProps): ReactNode;
|
|
142
|
+
export declare function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, t, subscribeLocale, setComposerText }: RewindPortalsProps): ReactNode;
|
|
143
|
+
/** The current composer draft, on whichever channel: rc.2 textarea `.value`,
|
|
144
|
+
* alpha.1+ contenteditable `textContent`. Empty when the composer is absent.
|
|
145
|
+
* Exported as a test seam (the empty-composer guard in `retractPending`). */
|
|
146
|
+
export declare function composerText(): string;
|
|
102
147
|
/**
|
|
103
148
|
* Build the slot-entry component for the plugin apply(): a tiny bridge that
|
|
104
149
|
* injects the apply-time capabilities (session resolution, locale, rewind
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -456,6 +456,13 @@ export declare class SnapshotStore {
|
|
|
456
456
|
* already absent, i.e. the target state is reached), a restore is a plain
|
|
457
457
|
* writeFile with a recursive mkdir of the parent. Returns how the outcome
|
|
458
458
|
* should record it.
|
|
459
|
+
*
|
|
460
|
+
* This is the only place the store writes restored content to the real FS,
|
|
461
|
+
* and it is deliberately a raw `writeFile`/`unlink` rather than the fs
|
|
462
|
+
* service: the caller only ever hands it a path from `planRestore` — one the
|
|
463
|
+
* session's own write-class tool call recorded and resolved (never a
|
|
464
|
+
* symlink/hard link) and only when it differs from the live disk. So no
|
|
465
|
+
* arbitrary path, no model input, never automatic.
|
|
459
466
|
*/
|
|
460
467
|
private applyActionToDisk;
|
|
461
468
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind — never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -154,6 +154,7 @@
|
|
|
154
154
|
"@types/react": "^18.3.31",
|
|
155
155
|
"@types/react-dom": "^18.3.7",
|
|
156
156
|
"esbuild": "^0.28.2",
|
|
157
|
+
"jsdom": "^30.0.1",
|
|
157
158
|
"react": "^18.3.1",
|
|
158
159
|
"react-dom": "^18.3.1",
|
|
159
160
|
"typescript": "^7.0.2",
|