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 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: only file-writing tools are tracked, a lightweight before-backup is **persisted on disk**, and your git repository is never touched or relied on. One lightweight plugin gives you a **complete** agentic rewind capability.
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
- - **轻量工作区备份**——行为对齐 Claude Code:只跟踪写文件的工具,写前做轻量备份并**落盘持久化**,不依赖、也不触碰 git 仓库。一个轻型插件,即拥有**完备的智能体回退能力**。
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
 
@@ -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
 
@@ -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
- - **Automatic detection**: `node scripts/check-dsh-version.mjs` compares the
62
- latest npm version against the tuples the peers cover and reports whether an
63
- extension is needed (exit 0 = nothing to do, exit 1 = update).
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
- - **自动检测**:`node scripts/check-dsh-version.mjs` 对比 npm 最新版本与
58
- peer 覆盖的元组,输出是否需追加(exit 0 无需动作,exit 1 需要)。
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(snapshot, match) {
341
+ function findCommand(chat, match) {
342
+ if (chat === void 0) return void 0;
337
343
  let found;
338
- for (const key of snapshot.chat.order) {
339
- const node = snapshot.chat.nodes.get(key);
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 snapshot = session.getSnapshot();
350
- for (const key of snapshot.chat.order) {
351
- const node = snapshot.chat.nodes.get(key);
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.getSnapshot(), match);
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(session, seq) {
674
- const snap = session.getSnapshot();
675
- for (const key of snap.chat.order) {
676
- const node = snap.chat.nodes.get(key);
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 fillComposer(text) {
686
- const textarea = document.querySelector(COMPOSER_SELECTOR);
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 userNodeOf(session, key) {
695
- const node = session.getSnapshot().chat.nodes.get(key);
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
- fillComposer(text);
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 textarea = document.querySelector(COMPOSER_SELECTOR);
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 (textarea !== null) {
722
- const card = textarea.closest("[data-composer-card]");
723
- const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : textarea.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 COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
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 = snapshot.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.nodes.get(key)?.anchorSeq : void 0;
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 next = [...collectTargets(chat, hiddenSeqs), ...collectPendingTargets(snapshot)];
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 textarea = document.querySelector(COMPOSER_SELECTOR);
917
- return textarea === null ? "" : textarea.value;
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
- fillComposer(text);
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 COMPOSER_SELECTOR2 = "[data-input-scroll] textarea, textarea[data-phase]";
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 chat = chatOf(sessionId);
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 textarea = composerTextarea();
1067
- const card = textarea?.closest("[data-composer-card]");
1068
- return card ?? textarea ?? document.body;
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 composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
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
- * Fill the dsh composer with `text` (React-controlled textarea: use the
71
- * native setter so the value change is seen, then dispatch an input event).
72
- * Best-effort no composer match means the fill is skipped. Used by
73
- * `runRewindAndFill` to put the withdrawn target message back into the
74
- * composer after a rewind.
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
@@ -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.0",
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",