dsh-rewind-plugin 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -32,6 +32,10 @@ __export(index_exports, {
32
32
  });
33
33
  module.exports = __toCommonJS(index_exports);
34
34
 
35
+ // src/client/portals.tsx
36
+ var import_react = require("react");
37
+ var import_react_dom = require("react-dom");
38
+
35
39
  // src/client/hidden.ts
36
40
  function targetOfOutcome(text) {
37
41
  if (text === void 0) return void 0;
@@ -85,48 +89,7 @@ function hiddenSeqsOf(snap) {
85
89
  return hidden;
86
90
  }
87
91
 
88
- // src/client/locales.ts
89
- var zh = {
90
- "button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
91
- "button.title": "\u56DE\u9000",
92
- "popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
93
- "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
94
- "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
95
- "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
96
- "popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
97
- "popover.both.hint": "\u5BF9\u8BDD\u56DE\u9000\u5E76\u8FD8\u539F\u5DE5\u4F5C\u533A\u6587\u4EF6",
98
- "popover.checking": "\u6B63\u5728\u68C0\u67E5\u6587\u4EF6\u53D8\u66F4\u2026",
99
- "popover.noChanges": "\u6B64\u6D88\u606F\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u6587\u4EF6\u53D8\u66F4\uFF0C\u4EC5\u53EF\u56DE\u9000\u5BF9\u8BDD",
100
- "popover.cancel": "\u53D6\u6D88",
101
- "popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
102
- "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
103
- "popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u8DDF\u8E2A\u5230\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002",
104
- "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
105
- "popover.back": "\u8FD4\u56DE",
106
- "guard.hint": "/rewind \u547D\u4EE4\u4E0D\u652F\u6301\u624B\u52A8\u8F93\u5165\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684 \u21B6 \u56DE\u9000\u6309\u94AE"
107
- };
108
- var en = {
109
- "button.aria": "Rewind to this message",
110
- "button.title": "Rewind",
111
- "popover.title": "Rewind to this message",
112
- "popover.noText": "(no text)",
113
- "popover.chat": "Rewind conversation only",
114
- "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
115
- "popover.both": "Rewind conversation and code",
116
- "popover.both.hint": "Cut the context and restore workspace files",
117
- "popover.checking": "Checking for file changes\u2026",
118
- "popover.noChanges": "No tracked file changes after this message; conversation-only rewind",
119
- "popover.cancel": "Cancel",
120
- "popover.impact.loading": "Fetching impact list\u2026",
121
- "popover.impact.failed": "Could not fetch the impact list: {message}",
122
- "popover.impact.none": "No tracked file changes after the target; nothing to restore.",
123
- "popover.confirm": "Confirm rewind",
124
- "popover.back": "Back",
125
- "guard.hint": "/rewind cannot be typed manually \u2014 use the \u21B6 button on a message to rewind."
126
- };
127
-
128
92
  // src/client/styles.ts
129
- var REWIND_ATTACHED = "data-dsh-rewind-attached";
130
93
  var CLASS = {
131
94
  button: "dsh-rewind-btn",
132
95
  popover: "dsh-rewind-popover",
@@ -497,14 +460,8 @@ function openPopover(opts) {
497
460
  };
498
461
  }
499
462
 
500
- // src/client/index.ts
501
- var name = "dsh-rewind";
502
- var inject = ["sessions", "locale"];
503
- var NS = "rewind";
504
- var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]';
505
- var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
506
- var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
507
- var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
463
+ // src/client/portals.tsx
464
+ var import_jsx_runtime = require("react/jsx-runtime");
508
465
  function messagePreviewOf(node) {
509
466
  const text = node.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
510
467
  return text.length <= 80 ? text : `${text.slice(0, 79)}\u2026`;
@@ -530,90 +487,82 @@ function fillComposer(text) {
530
487
  textarea.focus();
531
488
  return true;
532
489
  }
533
- function apply(ctx) {
534
- ctx.effect(function* () {
535
- yield ctx.locale.register(NS, { zh, en });
536
- const t = ctx.locale.bind(NS);
537
- const style = document.createElement("style");
538
- style.dataset.plugin = "dsh-rewind";
539
- style.textContent = STYLE;
540
- document.head.appendChild(style);
541
- const hidden = /* @__PURE__ */ new WeakSet();
542
- const buttons = /* @__PURE__ */ new Map();
543
- let observer = null;
544
- const refreshButtonLabels = () => {
545
- for (const button of buttons.values()) {
546
- button.setAttribute("aria-label", t("button.aria"));
547
- button.title = t("button.title");
548
- }
549
- };
550
- const unsubscribeLocale = ctx.locale.subscribe(refreshButtonLabels);
551
- const sessionFor = () => {
552
- const sessionId = ctx.sessions.list.getSnapshot().current;
553
- if (sessionId === void 0) return void 0;
554
- return ctx.sessions.binding(sessionId)?.session;
555
- };
556
- const userNodeFor = (key, session) => {
557
- const node = session.getSnapshot().chat.nodes.get(key);
558
- if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
559
- return node.data;
560
- };
561
- const attach = (seat) => {
562
- const key = seat.dataset.chatAnchorKey;
563
- if (key === void 0) return;
564
- const existing = buttons.get(key);
565
- if (existing !== void 0 && existing.isConnected) return;
566
- const hoverRoot = seat.querySelector(ACTIONS_ROOT_SELECTOR);
567
- const actions = hoverRoot?.lastElementChild;
568
- if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) return;
569
- const button = document.createElement("button");
570
- button.type = "button";
571
- button.className = CLASS.button;
572
- button.setAttribute("aria-label", t("button.aria"));
573
- button.title = t("button.title");
574
- button.innerHTML = REWIND_ICON_SVG;
575
- button.addEventListener("click", (event) => {
576
- event.stopPropagation();
577
- const session = sessionFor();
578
- if (session === void 0) {
579
- console.warn("[dsh-rewind] rewind button clicked with no current session");
580
- return;
581
- }
582
- const node = userNodeFor(key, session);
583
- if (node === void 0) return;
584
- openPopover({
585
- session,
586
- seq: node.seq,
587
- time: node.time,
588
- preview: messagePreviewOf(node),
589
- anchor: button,
590
- t,
591
- onRewind: (mode) => {
592
- void runRewindAndFill(session, node.seq, mode);
593
- }
594
- });
595
- });
596
- actions.appendChild(button);
597
- buttons.set(key, button);
598
- seat.setAttribute(REWIND_ATTACHED, "");
599
- };
600
- const scan = () => {
601
- for (const [key, button] of buttons) {
602
- if (!button.isConnected) buttons.delete(key);
490
+ function userNodeOf(session, key) {
491
+ const node = session.getSnapshot().chat.nodes.get(key);
492
+ if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
493
+ return node.data;
494
+ }
495
+ async function runRewindAndFill(session, seq, mode, currentSessionId) {
496
+ const known = knownCommandSeqs(session, (node) => isExecutedRewindCommand(node, seq));
497
+ const result = await session.command(`/rewind @${seq} ${mode}`);
498
+ if (!result.ok || result.value?.matched !== true) return;
499
+ const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
500
+ if (outcome === null || outcome.kind !== "success") return;
501
+ if (currentSessionId() !== session.sessionId) return;
502
+ const text = userTextAt(session, seq);
503
+ if (text === void 0 || text === "") return;
504
+ fillComposer(text);
505
+ }
506
+ var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
507
+ var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
508
+ var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
509
+ var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
510
+ function collectTargets(chat, hiddenSeqs) {
511
+ const rows = /* @__PURE__ */ new Map();
512
+ for (const element of document.querySelectorAll(USER_SEAT_SELECTOR)) {
513
+ const key = element.dataset.chatAnchorKey;
514
+ if (key !== void 0) rows.set(key, element);
515
+ }
516
+ const targets = [];
517
+ for (const key of chat.order) {
518
+ const node = chat.nodes.get(key);
519
+ if (node === void 0 || node.kind !== "user" && node.kind !== "steering") continue;
520
+ const user = node.data;
521
+ if (hiddenSeqs.has(node.anchorSeq ?? user.seq)) continue;
522
+ const row = rows.get(key);
523
+ const messageRoot = row?.querySelector(ACTIONS_ROOT_SELECTOR);
524
+ const actions = messageRoot?.lastElementChild;
525
+ if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
526
+ targets.push({ key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
527
+ }
528
+ return targets;
529
+ }
530
+ function sameTargets(left, right) {
531
+ return left.length === right.length && left.every((target, index) => {
532
+ const other = right[index];
533
+ return other !== void 0 && target.key === other.key && target.container === other.container && target.seq === other.seq;
534
+ });
535
+ }
536
+ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
537
+ const [targets, setTargets] = (0, import_react.useState)([]);
538
+ const hidden = (0, import_react.useRef)(/* @__PURE__ */ new WeakSet());
539
+ const [, forceRender] = (0, import_react.useReducer)((count) => count + 1, 0);
540
+ (0, import_react.useEffect)(() => subscribeLocale(() => {
541
+ forceRender();
542
+ }), [subscribeLocale]);
543
+ (0, import_react.useLayoutEffect)(() => {
544
+ let active = true;
545
+ let queued = false;
546
+ const refresh = () => {
547
+ if (!active) return;
548
+ const session = sessionOf(sessionId);
549
+ if (session === void 0) {
550
+ setTargets([]);
551
+ return;
603
552
  }
604
- const session = sessionFor();
605
- const hiddenSeqs = session !== void 0 ? hiddenSeqsOf(session.getSnapshot().chat) : /* @__PURE__ */ new Set();
553
+ const chat = session.getSnapshot().chat;
554
+ const hiddenSeqs = hiddenSeqsOf(chat);
606
555
  let hiddenCount = 0;
607
556
  for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
608
557
  const key = seat.dataset.chatAnchorKey;
609
- const anchor = key !== void 0 && session !== void 0 ? session.getSnapshot().chat.nodes.get(key)?.anchorSeq : void 0;
558
+ const anchor = key !== void 0 ? chat.nodes.get(key)?.anchorSeq : void 0;
610
559
  if (anchor !== void 0 && hiddenSeqs.has(anchor)) {
611
560
  seat.style.display = "none";
612
- hidden.add(seat);
561
+ hidden.current.add(seat);
613
562
  hiddenCount += 1;
614
- } else if (hidden.has(seat)) {
563
+ } else if (hidden.current.has(seat)) {
615
564
  seat.style.display = "";
616
- hidden.delete(seat);
565
+ hidden.current.delete(seat);
617
566
  }
618
567
  }
619
568
  if (hiddenSeqs.size > 0 || hiddenCount > 0) {
@@ -621,23 +570,151 @@ function apply(ctx) {
621
570
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
622
571
  );
623
572
  }
624
- for (const seat of document.querySelectorAll(USER_SEAT_SELECTOR)) {
625
- if (!hidden.has(seat)) attach(seat);
626
- }
573
+ const next = collectTargets(chat, hiddenSeqs);
574
+ setTargets((current) => sameTargets(current, next) ? current : next);
627
575
  };
628
- const runRewindAndFill = async (session, seq, mode) => {
629
- const known = knownCommandSeqs(session, (node) => isExecutedRewindCommand(node, seq));
630
- const result = await session.command(`/rewind @${seq} ${mode}`);
631
- if (!result.ok || result.value?.matched !== true) return;
632
- const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
633
- if (outcome === null || outcome.kind !== "success") return;
634
- if (sessionFor() !== session) return;
635
- const text = userTextAt(session, seq);
636
- if (text === void 0 || text === "") return;
637
- fillComposer(text);
576
+ const queueRefresh = () => {
577
+ if (queued || !active) return;
578
+ queued = true;
579
+ queueMicrotask(() => {
580
+ queued = false;
581
+ refresh();
582
+ });
638
583
  };
584
+ refresh();
585
+ const observer = new MutationObserver(queueRefresh);
586
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
587
+ return () => {
588
+ active = false;
589
+ observer.disconnect();
590
+ };
591
+ }, [sessionId, sessionOf]);
592
+ return targets.map((target) => (0, import_react_dom.createPortal)(
593
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
594
+ RewindButton,
595
+ {
596
+ target,
597
+ sessionId,
598
+ sessionOf,
599
+ currentSessionId,
600
+ t
601
+ },
602
+ target.key
603
+ ),
604
+ target.container,
605
+ target.key
606
+ ));
607
+ }
608
+ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
609
+ const onClick = (event) => {
610
+ event.stopPropagation();
611
+ const session = sessionOf(sessionId);
612
+ if (session === void 0) {
613
+ console.warn("[dsh-rewind] rewind button clicked with no session binding");
614
+ return;
615
+ }
616
+ const node = userNodeOf(session, target.key);
617
+ if (node === void 0) return;
618
+ openPopover({
619
+ session,
620
+ seq: node.seq,
621
+ time: node.time,
622
+ preview: messagePreviewOf(node),
623
+ anchor: event.currentTarget,
624
+ t,
625
+ onRewind: (mode) => {
626
+ void runRewindAndFill(session, node.seq, mode, currentSessionId);
627
+ }
628
+ });
629
+ };
630
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
631
+ "button",
632
+ {
633
+ type: "button",
634
+ className: CLASS.button,
635
+ "aria-label": t("button.aria"),
636
+ title: t("button.title"),
637
+ onClick,
638
+ dangerouslySetInnerHTML: { __html: REWIND_ICON_SVG }
639
+ }
640
+ );
641
+ }
642
+ function createRewindBridge(deps) {
643
+ return function RewindBridge({ sessionId }) {
644
+ return (0, import_react.createElement)(RewindPortals, { sessionId, ...deps });
645
+ };
646
+ }
647
+
648
+ // src/client/locales.ts
649
+ var zh = {
650
+ "button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
651
+ "button.title": "\u56DE\u9000",
652
+ "popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
653
+ "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
654
+ "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
655
+ "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
656
+ "popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
657
+ "popover.both.hint": "\u5BF9\u8BDD\u56DE\u9000\u5E76\u8FD8\u539F\u5DE5\u4F5C\u533A\u6587\u4EF6",
658
+ "popover.checking": "\u6B63\u5728\u68C0\u67E5\u6587\u4EF6\u53D8\u66F4\u2026",
659
+ "popover.noChanges": "\u6B64\u6D88\u606F\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u6587\u4EF6\u53D8\u66F4\uFF0C\u4EC5\u53EF\u56DE\u9000\u5BF9\u8BDD",
660
+ "popover.cancel": "\u53D6\u6D88",
661
+ "popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
662
+ "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
663
+ "popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u8DDF\u8E2A\u5230\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002",
664
+ "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
665
+ "popover.back": "\u8FD4\u56DE",
666
+ "guard.hint": "/rewind \u547D\u4EE4\u4E0D\u652F\u6301\u624B\u52A8\u8F93\u5165\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684 \u21B6 \u56DE\u9000\u6309\u94AE"
667
+ };
668
+ var en = {
669
+ "button.aria": "Rewind to this message",
670
+ "button.title": "Rewind",
671
+ "popover.title": "Rewind to this message",
672
+ "popover.noText": "(no text)",
673
+ "popover.chat": "Rewind conversation only",
674
+ "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
675
+ "popover.both": "Rewind conversation and code",
676
+ "popover.both.hint": "Cut the context and restore workspace files",
677
+ "popover.checking": "Checking for file changes\u2026",
678
+ "popover.noChanges": "No tracked file changes after this message; conversation-only rewind",
679
+ "popover.cancel": "Cancel",
680
+ "popover.impact.loading": "Fetching impact list\u2026",
681
+ "popover.impact.failed": "Could not fetch the impact list: {message}",
682
+ "popover.impact.none": "No tracked file changes after the target; nothing to restore.",
683
+ "popover.confirm": "Confirm rewind",
684
+ "popover.back": "Back",
685
+ "guard.hint": "/rewind cannot be typed manually \u2014 use the \u21B6 button on a message to rewind."
686
+ };
687
+
688
+ // src/client/index.ts
689
+ var name = "dsh-rewind";
690
+ var inject = ["slots", "sessions", "locale"];
691
+ var NS = "rewind";
692
+ var HEADER_ACTIONS_SLOT = "conversation.session.header.actions";
693
+ var COMPOSER_SELECTOR2 = "[data-input-scroll] textarea, textarea[data-phase]";
694
+ function apply(ctx) {
695
+ ctx.effect(function* () {
696
+ yield ctx.locale.register(NS, { zh, en });
697
+ const t = ctx.locale.bind(NS);
698
+ const style = document.createElement("style");
699
+ style.dataset.plugin = "dsh-rewind";
700
+ style.textContent = STYLE;
701
+ document.head.appendChild(style);
702
+ const sessionOf = (sessionId) => ctx.sessions.binding(sessionId)?.session;
703
+ const currentSessionId = () => ctx.sessions.list.getSnapshot().current;
704
+ const subscribeLocale = (cb) => ctx.locale.subscribe(cb);
705
+ const slots = ctx.slots;
706
+ yield slots.inject(HEADER_ACTIONS_SLOT, () => slots.register(
707
+ {
708
+ name: HEADER_ACTIONS_SLOT,
709
+ // A distinct list-entry id keeps the bridge from shadowing any other
710
+ // header action; the entry renders portals only, never header UI.
711
+ id: "dsh-rewind-portals",
712
+ order: 1e3
713
+ },
714
+ createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
715
+ ));
639
716
  const MANUAL_REWIND = /^\s*\/rewind(?:\s|$)/i;
640
- const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR);
717
+ const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
641
718
  const hasBlockedRewindDraft = () => {
642
719
  const textarea = composerTextarea();
643
720
  return textarea !== null && MANUAL_REWIND.test(textarea.value);
@@ -651,7 +728,7 @@ function apply(ctx) {
651
728
  if (textarea === null) return;
652
729
  const card = textarea.closest("[data-composer-card]");
653
730
  const hint = document.createElement("div");
654
- hint.className = CLASS.guardHint;
731
+ hint.className = "dsh-rewind-guard-hint";
655
732
  hint.setAttribute("role", "status");
656
733
  hint.textContent = t("guard.hint");
657
734
  document.body.appendChild(hint);
@@ -689,19 +766,11 @@ function apply(ctx) {
689
766
  };
690
767
  document.addEventListener("keydown", onKeyDownGuard, true);
691
768
  document.addEventListener("click", onClickGuard, true);
692
- observer = new MutationObserver(scan);
693
- observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
694
- scan();
695
769
  yield () => {
696
- unsubscribeLocale();
697
770
  document.removeEventListener("keydown", onKeyDownGuard, true);
698
771
  document.removeEventListener("click", onClickGuard, true);
699
772
  if (guardHintEl !== null) guardHintEl.remove();
700
773
  if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
701
- observer?.disconnect();
702
- for (const button of buttons.values()) button.remove();
703
- buttons.clear();
704
- closePopover();
705
774
  style.remove();
706
775
  };
707
776
  }, "dsh-rewind client lifecycle");
@@ -1,23 +1,19 @@
1
1
  /**
2
- * dsh-rewind client half: the per-user-message rewind button and the
3
- * mode-selection popover, injected into the conversation DOM (pure plugin
4
- * no harness source patches).
2
+ * dsh-rewind client half: the manual `/rewind` composer guard, the locale
3
+ * registration, and the session-scoped portal bridge that renders the
4
+ * per-message rewind button (see `portals.tsx` for the button itself).
5
5
  *
6
- * Anchoring: each chat node seat renders `[data-chat-flow-kind]` with
7
- * `data-chat-anchor-key`; a `MutationObserver` tracks newly rendered user
8
- * seats and appends the rewind button into the message's IconActions row.
9
- * The seq is never parsed from DOM text the key is looked up in the runtime
10
- * snapshot (`session.getSnapshot().chat.nodes.get(key)`) to get the durable
11
- * `UserMessageNode.seq`.
12
- *
13
- * Interaction: clicking a message's button fixes the target (step one), the
14
- * popover offers the two modes (step two); "rewind conversation and code"
15
- * first fetches the impact list via `/rewind preview @seq both` and confirms
16
- * before executing. Execution always goes through `session.command(...)`, the
17
- * same host path the `/rewind` command uses.
6
+ * The button is NOT injected by hand into the DOM anymore: the plugin
7
+ * registers a bridge into the harness's `conversation.session.header.actions`
8
+ * list slot, and that bridge portals a React button into every user message's
9
+ * IconActions row the same rendering family as the copy button (a React
10
+ * child of the actions row), without touching any harness source. The
11
+ * registration is typed structurally (see `SlotsLike` in portals.tsx), so the
12
+ * plugin never imports conversation UI types and survives harness version
13
+ * drift.
18
14
  *
19
15
  * Manual composer input of `/rewind` is deliberately blocked (the guard
20
- * below): the command exists only as the per-message button's internal
16
+ * below): the command exists only as the per-message button's internal
21
17
  * channel, so any `/rewind` line typed by hand — bare or with arguments — is
22
18
  * stopped with a hint pointing at the button.
23
19
  *
@@ -27,7 +23,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
27
23
  export declare const name = "dsh-rewind";
28
24
  export declare const inject: string[];
29
25
  /**
30
- * Client plugin body: button injection + popover wiring.
31
- * @param ctx - client root context carrying `sessions` and `locale`.
26
+ * Client plugin body: composer guard + locale + the portal bridge.
27
+ * @param ctx - client root context carrying `slots`, `sessions` and `locale`.
32
28
  */
33
29
  export declare function apply(ctx: ClientContext): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * dsh-rewind portal half: the per-user-message ↶ rewind button, rendered as a
3
+ * React portal inside the message's `MessageIconActions` row.
4
+ *
5
+ * Why portals (aligned with the copy button's own rendering): the copy button
6
+ * is a React child of the actions row, painted in the same commit as the
7
+ * bubble. A pure-DOM `appendChild` (the earlier approach) lands one microtask
8
+ * later and re-runs a full-transcript scan on EVERY mutation, which can push
9
+ * the paint of a newly sent bubble — the "occasional hiccup before the bubble
10
+ * shows". Portals let React own the button lifecycle (mount/unmount with the
11
+ * row, no orphaned buttons, no manual re-attach after harness re-renders),
12
+ * and the target collection is coalesced (one refresh per mutation batch) and
13
+ * diffed (no setState churn when nothing changed).
14
+ *
15
+ * Mount point: the plugin registers a session-scoped bridge into the harness's
16
+ * `conversation.session.header.actions` list slot. The bridge renders NO
17
+ * header UI — it only portals buttons into the user rows of the session the
18
+ * harness mounts it for. That slot is the harness-native way to get a
19
+ * per-session React mount without touching any source; the registration is
20
+ * typed structurally (see `SlotsLike`) so the plugin never imports the
21
+ * conversation UI package's types and survives its version drift.
22
+ *
23
+ * @module dsh-rewind/client/portals
24
+ */
25
+ import { type ReactNode } from 'react';
26
+ import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
27
+ import type { RewindKey } from './locales.ts';
28
+ type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
29
+ /** Capabilities the session-scoped bridge receives from the plugin apply(). */
30
+ export interface RewindBridgeDeps {
31
+ readonly sessionOf: (sessionId: string) => SessionFace | undefined;
32
+ readonly currentSessionId: () => string | undefined;
33
+ readonly t: Translate;
34
+ readonly subscribeLocale: (cb: () => void) => () => void;
35
+ }
36
+ /** Structural face of the runtime slot service (see the module doc). */
37
+ export interface SlotsLike {
38
+ inject(key: string, install: () => () => void): () => void;
39
+ register(entry: {
40
+ readonly name: string;
41
+ readonly id: string;
42
+ readonly order: number;
43
+ }, component: (props: {
44
+ readonly sessionId: string;
45
+ }) => ReactNode): () => void;
46
+ }
47
+ interface RewindPortalsProps extends RewindBridgeDeps {
48
+ readonly sessionId: string;
49
+ }
50
+ /**
51
+ * Session-scoped portal bridge: renders the ↶ button of every user message
52
+ * row of the session the harness mounts it for. The refresh is coalesced
53
+ * (one pass per mutation batch via queueMicrotask) and diffed (setState is
54
+ * skipped when the target set is unchanged), so the plugin never runs a
55
+ * synchronous full-transcript scan inside a commit microtask.
56
+ */
57
+ export declare function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }: RewindPortalsProps): ReactNode;
58
+ /**
59
+ * Build the slot-entry component for the plugin apply(): a tiny bridge that
60
+ * injects the apply-time capabilities (session resolution, locale, rewind
61
+ * runner) into the module-level `RewindPortals`.
62
+ */
63
+ export declare function createRewindBridge(deps: RewindBridgeDeps): (props: {
64
+ readonly sessionId: string;
65
+ }) => ReactNode;
66
+ export {};
@@ -5,8 +5,6 @@
5
5
  *
6
6
  * @module dsh-rewind/client/styles
7
7
  */
8
- /** Marker attribute set on a seat row once its rewind button is attached. */
9
- export declare const REWIND_ATTACHED = "data-dsh-rewind-attached";
10
8
  /** Class names shared between the injected DOM and the stylesheet. */
11
9
  export declare const CLASS: {
12
10
  readonly button: 'dsh-rewind-btn';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -54,7 +54,8 @@
54
54
  "client": {
55
55
  "inject": [
56
56
  "@deepseek-ai/dsh-client-locale",
57
- "@deepseek-ai/dsh-client-runtime"
57
+ "@deepseek-ai/dsh-client-runtime",
58
+ "@deepseek-ai/dsh-client-ui-conversation"
58
59
  ],
59
60
  "platform": "web"
60
61
  }
@@ -126,7 +127,11 @@
126
127
  "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
127
128
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
128
129
  "@types/node": "^24.0.0",
130
+ "@types/react": "^18.3.31",
131
+ "@types/react-dom": "^18.3.7",
129
132
  "esbuild": "^0.28.2",
133
+ "react": "^18.3.1",
134
+ "react-dom": "^18.3.1",
130
135
  "typescript": "^7.0.2",
131
136
  "vitest": "^4.1.10"
132
137
  }