dsh-rewind-plugin 0.2.7 → 0.2.9

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,84 @@ 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
+ seat.dataset.dshRewindHidden = "true";
562
+ hidden.current.add(seat);
613
563
  hiddenCount += 1;
614
- } else if (hidden.has(seat)) {
564
+ } else if (hidden.current.has(seat)) {
615
565
  seat.style.display = "";
616
- hidden.delete(seat);
566
+ delete seat.dataset.dshRewindHidden;
567
+ hidden.current.delete(seat);
617
568
  }
618
569
  }
619
570
  if (hiddenSeqs.size > 0 || hiddenCount > 0) {
@@ -621,23 +572,151 @@ function apply(ctx) {
621
572
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
622
573
  );
623
574
  }
624
- for (const seat of document.querySelectorAll(USER_SEAT_SELECTOR)) {
625
- if (!hidden.has(seat)) attach(seat);
626
- }
575
+ const next = collectTargets(chat, hiddenSeqs);
576
+ setTargets((current) => sameTargets(current, next) ? current : next);
627
577
  };
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);
578
+ const queueRefresh = () => {
579
+ if (queued || !active) return;
580
+ queued = true;
581
+ queueMicrotask(() => {
582
+ queued = false;
583
+ refresh();
584
+ });
638
585
  };
586
+ refresh();
587
+ const observer = new MutationObserver(queueRefresh);
588
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
589
+ return () => {
590
+ active = false;
591
+ observer.disconnect();
592
+ };
593
+ }, [sessionId, sessionOf]);
594
+ return targets.map((target) => (0, import_react_dom.createPortal)(
595
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
596
+ RewindButton,
597
+ {
598
+ target,
599
+ sessionId,
600
+ sessionOf,
601
+ currentSessionId,
602
+ t
603
+ },
604
+ target.key
605
+ ),
606
+ target.container,
607
+ target.key
608
+ ));
609
+ }
610
+ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
611
+ const onClick = (event) => {
612
+ event.stopPropagation();
613
+ const session = sessionOf(sessionId);
614
+ if (session === void 0) {
615
+ console.warn("[dsh-rewind] rewind button clicked with no session binding");
616
+ return;
617
+ }
618
+ const node = userNodeOf(session, target.key);
619
+ if (node === void 0) return;
620
+ openPopover({
621
+ session,
622
+ seq: node.seq,
623
+ time: node.time,
624
+ preview: messagePreviewOf(node),
625
+ anchor: event.currentTarget,
626
+ t,
627
+ onRewind: (mode) => {
628
+ void runRewindAndFill(session, node.seq, mode, currentSessionId);
629
+ }
630
+ });
631
+ };
632
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
633
+ "button",
634
+ {
635
+ type: "button",
636
+ className: CLASS.button,
637
+ "aria-label": t("button.aria"),
638
+ title: t("button.title"),
639
+ onClick,
640
+ dangerouslySetInnerHTML: { __html: REWIND_ICON_SVG }
641
+ }
642
+ );
643
+ }
644
+ function createRewindBridge(deps) {
645
+ return function RewindBridge({ sessionId }) {
646
+ return (0, import_react.createElement)(RewindPortals, { sessionId, ...deps });
647
+ };
648
+ }
649
+
650
+ // src/client/locales.ts
651
+ var zh = {
652
+ "button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
653
+ "button.title": "\u56DE\u9000",
654
+ "popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
655
+ "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
656
+ "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
657
+ "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
658
+ "popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
659
+ "popover.both.hint": "\u5BF9\u8BDD\u56DE\u9000\u5E76\u8FD8\u539F\u5DE5\u4F5C\u533A\u6587\u4EF6",
660
+ "popover.checking": "\u6B63\u5728\u68C0\u67E5\u6587\u4EF6\u53D8\u66F4\u2026",
661
+ "popover.noChanges": "\u6B64\u6D88\u606F\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u6587\u4EF6\u53D8\u66F4\uFF0C\u4EC5\u53EF\u56DE\u9000\u5BF9\u8BDD",
662
+ "popover.cancel": "\u53D6\u6D88",
663
+ "popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
664
+ "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
665
+ "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",
666
+ "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
667
+ "popover.back": "\u8FD4\u56DE",
668
+ "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"
669
+ };
670
+ var en = {
671
+ "button.aria": "Rewind to this message",
672
+ "button.title": "Rewind",
673
+ "popover.title": "Rewind to this message",
674
+ "popover.noText": "(no text)",
675
+ "popover.chat": "Rewind conversation only",
676
+ "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
677
+ "popover.both": "Rewind conversation and code",
678
+ "popover.both.hint": "Cut the context and restore workspace files",
679
+ "popover.checking": "Checking for file changes\u2026",
680
+ "popover.noChanges": "No tracked file changes after this message; conversation-only rewind",
681
+ "popover.cancel": "Cancel",
682
+ "popover.impact.loading": "Fetching impact list\u2026",
683
+ "popover.impact.failed": "Could not fetch the impact list: {message}",
684
+ "popover.impact.none": "No tracked file changes after the target; nothing to restore.",
685
+ "popover.confirm": "Confirm rewind",
686
+ "popover.back": "Back",
687
+ "guard.hint": "/rewind cannot be typed manually \u2014 use the \u21B6 button on a message to rewind."
688
+ };
689
+
690
+ // src/client/index.ts
691
+ var name = "dsh-rewind";
692
+ var inject = ["slots", "sessions", "locale"];
693
+ var NS = "rewind";
694
+ var HEADER_ACTIONS_SLOT = "conversation.session.header.actions";
695
+ var COMPOSER_SELECTOR2 = "[data-input-scroll] textarea, textarea[data-phase]";
696
+ function apply(ctx) {
697
+ ctx.effect(function* () {
698
+ yield ctx.locale.register(NS, { zh, en });
699
+ const t = ctx.locale.bind(NS);
700
+ const style = document.createElement("style");
701
+ style.dataset.plugin = "dsh-rewind";
702
+ style.textContent = STYLE;
703
+ document.head.appendChild(style);
704
+ const sessionOf = (sessionId) => ctx.sessions.binding(sessionId)?.session;
705
+ const currentSessionId = () => ctx.sessions.list.getSnapshot().current;
706
+ const subscribeLocale = (cb) => ctx.locale.subscribe(cb);
707
+ const slots = ctx.slots;
708
+ yield slots.inject(HEADER_ACTIONS_SLOT, () => slots.register(
709
+ {
710
+ name: HEADER_ACTIONS_SLOT,
711
+ // A distinct list-entry id keeps the bridge from shadowing any other
712
+ // header action; the entry renders portals only, never header UI.
713
+ id: "dsh-rewind-portals",
714
+ order: 1e3
715
+ },
716
+ createRewindBridge({ sessionOf, currentSessionId, t, subscribeLocale })
717
+ ));
639
718
  const MANUAL_REWIND = /^\s*\/rewind(?:\s|$)/i;
640
- const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR);
719
+ const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
641
720
  const hasBlockedRewindDraft = () => {
642
721
  const textarea = composerTextarea();
643
722
  return textarea !== null && MANUAL_REWIND.test(textarea.value);
@@ -651,7 +730,7 @@ function apply(ctx) {
651
730
  if (textarea === null) return;
652
731
  const card = textarea.closest("[data-composer-card]");
653
732
  const hint = document.createElement("div");
654
- hint.className = CLASS.guardHint;
733
+ hint.className = "dsh-rewind-guard-hint";
655
734
  hint.setAttribute("role", "status");
656
735
  hint.textContent = t("guard.hint");
657
736
  document.body.appendChild(hint);
@@ -689,19 +768,11 @@ function apply(ctx) {
689
768
  };
690
769
  document.addEventListener("keydown", onKeyDownGuard, true);
691
770
  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
771
  yield () => {
696
- unsubscribeLocale();
697
772
  document.removeEventListener("keydown", onKeyDownGuard, true);
698
773
  document.removeEventListener("click", onClickGuard, true);
699
774
  if (guardHintEl !== null) guardHintEl.remove();
700
775
  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
776
  style.remove();
706
777
  };
707
778
  }, "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.9",
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
  }