dsh-rewind-plugin 0.3.2 → 0.3.3

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
@@ -467,9 +467,96 @@ function renderImpactStep(root, opts, back, cached) {
467
467
  impact.textContent = t("popover.impact.failed", { message: "unexpected error" });
468
468
  });
469
469
  }
470
+ function mountShell(root, anchor, onKeyDown) {
471
+ const position = () => {
472
+ const rect = anchor.getBoundingClientRect();
473
+ const gap = 4;
474
+ const height = root.offsetHeight;
475
+ const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
476
+ root.style.top = `${Math.round(top)}px`;
477
+ root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
478
+ };
479
+ const onPointerDown = (event) => {
480
+ const target = event.target;
481
+ if (root.contains(target) || anchor.contains(target)) return;
482
+ closePopover();
483
+ };
484
+ const deferred = setTimeout(() => {
485
+ document.addEventListener("pointerdown", onPointerDown);
486
+ document.addEventListener("keydown", onKeyDown, true);
487
+ }, 0);
488
+ const dispose = () => {
489
+ clearTimeout(deferred);
490
+ document.removeEventListener("pointerdown", onPointerDown);
491
+ document.removeEventListener("keydown", onKeyDown, true);
492
+ };
493
+ document.body.append(root);
494
+ position();
495
+ return { position, dispose };
496
+ }
497
+ function openRetractPopover(opts) {
498
+ closePopover();
499
+ const { preview, anchor, t, retract, onRetract } = opts;
500
+ if (retract === void 0 || onRetract === void 0) return;
501
+ const root = el("div", CLASS.popover);
502
+ root.setAttribute("role", "dialog");
503
+ root.setAttribute("aria-label", t("popover.retract.title"));
504
+ const onKeyDown = (event) => {
505
+ if (event.key === "ArrowDown") {
506
+ event.preventDefault();
507
+ event.stopPropagation();
508
+ moveFocus(root, 1);
509
+ return;
510
+ }
511
+ if (event.key === "ArrowUp") {
512
+ event.preventDefault();
513
+ event.stopPropagation();
514
+ moveFocus(root, -1);
515
+ return;
516
+ }
517
+ if (event.key === "Escape") {
518
+ event.preventDefault();
519
+ event.stopPropagation();
520
+ closePopover();
521
+ }
522
+ };
523
+ const previewText = preview.length > 0 ? preview : t("popover.noText");
524
+ const actions = el("div", CLASS.popoverActions);
525
+ const confirm = document.createElement("button");
526
+ confirm.type = "button";
527
+ confirm.className = CLASS.popoverPrimary;
528
+ confirm.textContent = t("popover.retract.confirm");
529
+ confirm.addEventListener("click", () => {
530
+ closePopover();
531
+ onRetract();
532
+ });
533
+ const cancel = document.createElement("button");
534
+ cancel.type = "button";
535
+ cancel.className = CLASS.popoverGhost;
536
+ cancel.textContent = t("popover.cancel");
537
+ cancel.addEventListener("click", closePopover);
538
+ actions.append(confirm, cancel);
539
+ root.replaceChildren(
540
+ el("div", CLASS.popoverTitle, t("popover.retract.title")),
541
+ el("div", CLASS.popoverTarget, t("popover.retract.target", { preview: previewText })),
542
+ el("div", CLASS.popoverImpact, t("popover.retract.hint")),
543
+ actions
544
+ );
545
+ const shell = mountShell(root, anchor, onKeyDown);
546
+ popoverEl = root;
547
+ disposeOutside = shell.dispose;
548
+ focusFirst(root);
549
+ }
470
550
  function openPopover(opts) {
471
551
  closePopover();
552
+ if (opts.retract !== void 0) {
553
+ openRetractPopover(opts);
554
+ return;
555
+ }
472
556
  const { session, seq, time, preview, anchor, t } = opts;
557
+ const onRewind = opts.onRewind;
558
+ if (seq === void 0 || time === void 0 || onRewind === void 0) return;
559
+ const durableOpts = { session, seq, time, preview, anchor, t, onRewind };
473
560
  const root = el("div", CLASS.popover);
474
561
  root.setAttribute("role", "dialog");
475
562
  root.setAttribute("aria-label", t("popover.title"));
@@ -483,7 +570,7 @@ function openPopover(opts) {
483
570
  el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
484
571
  modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
485
572
  closePopover();
486
- opts.onRewind("chat");
573
+ durableOpts.onRewind("chat");
487
574
  })
488
575
  ];
489
576
  if (bothState.state === "noChanges") {
@@ -510,38 +597,7 @@ function openPopover(opts) {
510
597
  };
511
598
  const renderImpact = () => {
512
599
  step = "impact";
513
- renderImpactStep(root, opts, renderModes, impactOutcome);
514
- };
515
- const position = () => {
516
- const rect = anchor.getBoundingClientRect();
517
- const gap = 4;
518
- const height = root.offsetHeight;
519
- const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
520
- root.style.top = `${Math.round(top)}px`;
521
- root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
522
- };
523
- renderModes();
524
- document.body.append(root);
525
- position();
526
- focusFirst(root);
527
- void (async () => {
528
- const outcome = await previewImpact(session, seq);
529
- impactOutcome = outcome;
530
- if (outcome !== null && outcome.kind === "success") {
531
- bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
532
- }
533
- renderModes();
534
- position();
535
- })().catch(() => {
536
- bothState = { state: "hasChanges" };
537
- renderModes();
538
- position();
539
- });
540
- popoverEl = root;
541
- const onPointerDown = (event) => {
542
- const target = event.target;
543
- if (root.contains(target) || anchor.contains(target)) return;
544
- closePopover();
600
+ renderImpactStep(root, durableOpts, renderModes, impactOutcome);
545
601
  };
546
602
  const onKeyDown = (event) => {
547
603
  if (event.key === "ArrowDown") {
@@ -563,20 +619,50 @@ function openPopover(opts) {
563
619
  else closePopover();
564
620
  }
565
621
  };
566
- const deferred = setTimeout(() => {
567
- document.addEventListener("pointerdown", onPointerDown);
568
- document.addEventListener("keydown", onKeyDown, true);
569
- }, 0);
570
- disposeOutside = () => {
571
- clearTimeout(deferred);
572
- document.removeEventListener("pointerdown", onPointerDown);
573
- document.removeEventListener("keydown", onKeyDown, true);
574
- };
622
+ renderModes();
623
+ const shell = mountShell(root, anchor, onKeyDown);
624
+ popoverEl = root;
625
+ disposeOutside = shell.dispose;
626
+ void (async () => {
627
+ const outcome = await previewImpact(session, seq);
628
+ impactOutcome = outcome;
629
+ if (outcome !== null && outcome.kind === "success") {
630
+ bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
631
+ }
632
+ renderModes();
633
+ shell.position();
634
+ })().catch(() => {
635
+ bothState = { state: "hasChanges" };
636
+ renderModes();
637
+ shell.position();
638
+ });
575
639
  }
576
640
 
577
641
  // src/client/portals.tsx
578
642
  var import_react = require("react");
579
643
  var import_react_dom = require("react-dom");
644
+
645
+ // src/client/pending.ts
646
+ function matchPendingRows(rows, steering) {
647
+ const matched = [];
648
+ for (let i = 0; i < rows.length; i++) {
649
+ const row = rows[i];
650
+ const item = steering[i];
651
+ if (item !== void 0 && row.text === (item.text ?? "")) {
652
+ matched.push(item.id);
653
+ } else {
654
+ matched.push(null);
655
+ }
656
+ }
657
+ return matched;
658
+ }
659
+ function retractSpan(steering, targetId) {
660
+ const index = steering.findIndex((item) => item.id === targetId);
661
+ if (index === -1) return [];
662
+ return steering.slice(index).map((item) => item.id);
663
+ }
664
+
665
+ // src/client/portals.tsx
580
666
  var import_jsx_runtime = require("react/jsx-runtime");
581
667
  function userTextAt(session, seq) {
582
668
  const snap = session.getSnapshot();
@@ -619,6 +705,7 @@ var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
619
705
  var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
620
706
  var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
621
707
  var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
708
+ var PENDING_SEAT_SELECTOR = "[data-pending-steering][data-time-hover-root]";
622
709
  function collectTargets(chat, hiddenSeqs) {
623
710
  const rows = /* @__PURE__ */ new Map();
624
711
  for (const element of document.querySelectorAll(USER_SEAT_SELECTOR)) {
@@ -635,14 +722,51 @@ function collectTargets(chat, hiddenSeqs) {
635
722
  const messageRoot = row?.querySelector(ACTIONS_ROOT_SELECTOR);
636
723
  const actions = messageRoot?.lastElementChild;
637
724
  if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
638
- targets.push({ key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
725
+ targets.push({ kind: "durable", key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
726
+ }
727
+ return targets;
728
+ }
729
+ function bubbleTextOf(row) {
730
+ const clone = row.cloneNode(true);
731
+ clone.lastElementChild?.remove();
732
+ return clone.textContent ?? "";
733
+ }
734
+ function collectPendingTargets(snapshot) {
735
+ if (snapshot.subagent !== null) return [];
736
+ const steering = snapshot.queue.filter((item) => item.placement === "steering");
737
+ if (steering.length === 0) return [];
738
+ const rows = Array.from(document.querySelectorAll(PENDING_SEAT_SELECTOR));
739
+ const matched = matchPendingRows(
740
+ rows.map((row) => ({ text: bubbleTextOf(row) })),
741
+ steering.map((item) => ({ id: item.id, text: item.text }))
742
+ );
743
+ const targets = [];
744
+ for (let i = 0; i < matched.length; i++) {
745
+ const itemId = matched[i];
746
+ if (itemId === null) continue;
747
+ const row = rows[i];
748
+ if (row === void 0) continue;
749
+ const messageRoot = row.matches(ACTIONS_ROOT_SELECTOR) ? row : row.querySelector(ACTIONS_ROOT_SELECTOR);
750
+ const actions = messageRoot?.lastElementChild;
751
+ if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
752
+ const item = steering[i];
753
+ targets.push({
754
+ kind: "pending",
755
+ key: `pending:${itemId}`,
756
+ container: actions,
757
+ itemId,
758
+ text: item.text,
759
+ preview: item.preview
760
+ });
639
761
  }
640
762
  return targets;
641
763
  }
642
764
  function sameTargets(left, right) {
643
765
  return left.length === right.length && left.every((target, index) => {
644
766
  const other = right[index];
645
- return other !== void 0 && target.key === other.key && target.container === other.container && target.seq === other.seq;
767
+ if (other === void 0 || target.key !== other.key || target.container !== other.container) return false;
768
+ if (target.kind === "durable") return other.kind === "durable" && target.seq === other.seq;
769
+ return other.kind === "pending" && target.itemId === other.itemId;
646
770
  });
647
771
  }
648
772
  function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
@@ -662,7 +786,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
662
786
  setTargets([]);
663
787
  return;
664
788
  }
665
- const chat = session.getSnapshot().chat;
789
+ const snapshot = session.getSnapshot();
790
+ const chat = snapshot.chat;
666
791
  const hiddenSeqs = hiddenSeqsOf(chat);
667
792
  let hiddenCount = 0;
668
793
  for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
@@ -684,7 +809,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
684
809
  `[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
685
810
  );
686
811
  }
687
- const next = collectTargets(chat, hiddenSeqs);
812
+ const next = [...collectTargets(chat, hiddenSeqs), ...collectPendingTargets(snapshot)];
688
813
  setTargets((current) => sameTargets(current, next) ? current : next);
689
814
  };
690
815
  const queueRefresh = () => {
@@ -704,7 +829,16 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
704
829
  };
705
830
  }, [sessionId, sessionOf]);
706
831
  return targets.map((target) => (0, import_react_dom.createPortal)(
707
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
832
+ target.kind === "pending" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
833
+ RetractButton,
834
+ {
835
+ target,
836
+ sessionId,
837
+ sessionOf,
838
+ t
839
+ },
840
+ target.key
841
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
708
842
  RewindButton,
709
843
  {
710
844
  target,
@@ -753,6 +887,49 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
753
887
  }
754
888
  );
755
889
  }
890
+ function composerText() {
891
+ const textarea = document.querySelector(COMPOSER_SELECTOR);
892
+ return textarea === null ? "" : textarea.value;
893
+ }
894
+ async function retractPending(session, itemId, text) {
895
+ await session.cancel();
896
+ const queue = session.getSnapshot().queue;
897
+ const steering = queue.filter((item) => item.placement === "steering");
898
+ for (const id of retractSpan(steering, itemId)) {
899
+ await session.updateQueue(id, { kind: "remove" });
900
+ }
901
+ if (text !== null && text !== "" && composerText().trim() === "") {
902
+ fillComposer(text);
903
+ }
904
+ }
905
+ function RetractButton({ target, sessionId, sessionOf, t }) {
906
+ const onClick = (event) => {
907
+ event.stopPropagation();
908
+ const session = sessionOf(sessionId);
909
+ if (session === void 0) return;
910
+ openPopover({
911
+ session,
912
+ preview: target.preview,
913
+ anchor: event.currentTarget,
914
+ t,
915
+ retract: { itemId: target.itemId, text: target.text },
916
+ onRetract: () => {
917
+ void retractPending(session, target.itemId, target.text);
918
+ }
919
+ });
920
+ };
921
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
922
+ "button",
923
+ {
924
+ type: "button",
925
+ className: CLASS.button,
926
+ "aria-label": t("button.retract.aria"),
927
+ title: t("button.retract.title"),
928
+ onClick,
929
+ dangerouslySetInnerHTML: { __html: REWIND_ICON_SVG }
930
+ }
931
+ );
932
+ }
756
933
  function createRewindBridge(deps) {
757
934
  return function RewindBridge({ sessionId }) {
758
935
  return (0, import_react.createElement)(RewindPortals, { sessionId, ...deps });
@@ -763,8 +940,14 @@ function createRewindBridge(deps) {
763
940
  var zh = {
764
941
  "button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
765
942
  "button.title": "\u56DE\u9000",
943
+ "button.retract.aria": "\u56DE\u9000\u5230\u6B64\u63D2\u8BDD\u6D88\u606F",
944
+ "button.retract.title": "\u56DE\u9000",
766
945
  "popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
767
946
  "popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
947
+ "popover.retract.title": "\u56DE\u9000\u5230\u8FD9\u6761\u63D2\u8BDD\u6D88\u606F",
948
+ "popover.retract.target": "\u63D2\u8BDD\u4E2D \xB7 {preview}",
949
+ "popover.retract.hint": "\u5C06\u505C\u6B62\u5F53\u524D\u751F\u6210\uFF0C\u5E76\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D",
950
+ "popover.retract.confirm": "\u786E\u8BA4\u56DE\u9000",
768
951
  "popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
769
952
  "popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
770
953
  "popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
@@ -784,8 +967,14 @@ var zh = {
784
967
  var en = {
785
968
  "button.aria": "Rewind to this message",
786
969
  "button.title": "Rewind",
970
+ "button.retract.aria": "Rewind to this pending message",
971
+ "button.retract.title": "Rewind",
787
972
  "popover.title": "Rewind to this message",
788
973
  "popover.noText": "(no text)",
974
+ "popover.retract.title": "Rewind to this pending message",
975
+ "popover.retract.target": "Pending \xB7 {preview}",
976
+ "popover.retract.hint": "Stops the current run and rewinds to before this message",
977
+ "popover.retract.confirm": "Confirm rewind",
789
978
  "popover.chat": "Rewind conversation only",
790
979
  "popover.chat.hint": "Cut the model context only; workspace files stay untouched",
791
980
  "popover.both": "Rewind conversation and code",
package/lib/index.js CHANGED
@@ -653,6 +653,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
653
653
  if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
654
654
  }
655
655
  }
656
+ function dropPendingSteering(agent) {
657
+ for (const message of [...agent.inbox.nextStep]) {
658
+ agent.inbox.remove(message.id);
659
+ }
660
+ }
656
661
  async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
657
662
  const { agent } = invocation;
658
663
  const sessionId = agent.session.id;
@@ -662,12 +667,13 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
662
667
  inflight.add(sessionId);
663
668
  try {
664
669
  if (agent.status !== "idle") {
665
- agent.cancel({ kind: "user" });
670
+ agent.cancel({ kind: "user" }, { keepInbox: true });
666
671
  const stopped = await waitForAgentIdle(agent, invocation.signal);
667
672
  if (!stopped) {
668
673
  return { kind: "error", text: t("stopFailed") };
669
674
  }
670
675
  }
676
+ dropPendingSteering(agent);
671
677
  if (invocation.signal.aborted) {
672
678
  return { kind: "error", text: t("cancelled") };
673
679
  }
@@ -3,8 +3,14 @@
3
3
  export declare const zh: {
4
4
  'button.aria': string;
5
5
  'button.title': string;
6
+ 'button.retract.aria': string;
7
+ 'button.retract.title': string;
6
8
  'popover.title': string;
7
9
  'popover.noText': string;
10
+ 'popover.retract.title': string;
11
+ 'popover.retract.target': string;
12
+ 'popover.retract.hint': string;
13
+ 'popover.retract.confirm': string;
8
14
  'popover.chat': string;
9
15
  'popover.chat.hint': string;
10
16
  'popover.both': string;
@@ -33,8 +39,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
39
  export declare const en: {
34
40
  'button.aria': string;
35
41
  'button.title': string;
42
+ 'button.retract.aria': string;
43
+ 'button.retract.title': string;
36
44
  'popover.title': string;
37
45
  'popover.noText': string;
46
+ 'popover.retract.title': string;
47
+ 'popover.retract.target': string;
48
+ 'popover.retract.hint': string;
49
+ 'popover.retract.confirm': string;
38
50
  'popover.chat': string;
39
51
  'popover.chat.hint': string;
40
52
  'popover.both': string;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure pending-message matching: pairs the rendered pending-steering bubble
3
+ * rows with the session's transient queue mirror rows (`placement === 'steering'`).
4
+ *
5
+ * Both sides derive from the host's next-step inbox order — the ChatView
6
+ * renders `pendingSteering` in array order and the queue mirror keeps the same
7
+ * host order — so index-primary matching is reliable. Text equality is still
8
+ * verified as a per-row cross-check, and a row that fails (or a row with no
9
+ * mirror item, or a mirror item with no row) is skipped INDIVIDUALLY: one bad
10
+ * row never takes down the other rows' buttons. The matching text is the
11
+ * bubble's message text WITHOUT its actions container — the harness copy
12
+ * button's Tooltip mounts a label bubble inside that container on hover, so
13
+ * the full row textContent would flip between "message" and "message+Copy"
14
+ * with the mouse, flickering the button (see `bubbleTextOf` in portals.tsx).
15
+ *
16
+ * The browser half lives in `portals.tsx`; this module stays DOM-free so the
17
+ * matching contract is unit-testable in a plain node environment.
18
+ *
19
+ * @module dsh-rewind/client/pending
20
+ */
21
+ /** One rendered pending-steering bubble row (only the fields matching reads). */
22
+ export interface PendingRow {
23
+ /** The bubble's message text, excluding the actions container (see module doc). */
24
+ readonly text: string;
25
+ }
26
+ /** One steering occurrence from the session queue mirror. */
27
+ export interface PendingSteeringItem {
28
+ readonly id: string;
29
+ /** Complete editable text; null when the message contains non-text blocks. */
30
+ readonly text: string | null;
31
+ }
32
+ /**
33
+ * Pair rows to steering items by index, verifying text equality per row.
34
+ * @param rows - pending bubble rows in DOM order (== render order).
35
+ * @param steering - steering queue items in host order (== render order).
36
+ * @returns the item id for each row, or null for rows that cannot be matched
37
+ * safely (missing counterpart, text mismatch). A bad row never affects the
38
+ * other rows.
39
+ */
40
+ export declare function matchPendingRows(rows: readonly PendingRow[], steering: readonly PendingSteeringItem[]): readonly (string | null)[];
41
+ /**
42
+ * The pending-steering ids a "rewind to this pre-sent message" retracts: the
43
+ * target occurrence and every steering message after it, in inbox (FIFO)
44
+ * order. Queued (next-turn) messages are deliberately NOT included — the
45
+ * harness QueueDock already offers the user per-item edit/remove, so a rewind
46
+ * must not silently drop messages the user may still want to send.
47
+ * @param steering - steering queue items in host order (== render order).
48
+ * @param targetId - the rewind target's inbox occurrence id.
49
+ * @returns the ids to remove, oldest-first; empty when the target is no
50
+ * longer pending (already claimed/consumed).
51
+ */
52
+ export declare function retractSpan(steering: readonly {
53
+ readonly id: string;
54
+ }[], targetId: string): readonly string[];
@@ -19,8 +19,17 @@ import type { RewindKey } from './locales.ts';
19
19
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
20
20
  export interface PopoverOptions {
21
21
  readonly session: SessionFace;
22
- readonly seq: number;
23
- readonly time: number;
22
+ /** Durable variant: the target message seq (mode-selection flow). */
23
+ readonly seq?: number;
24
+ /** Durable variant: the target message time. */
25
+ readonly time?: number;
26
+ /** Pending variant: retract a pre-sent steering message (single-confirm flow). */
27
+ readonly retract?: {
28
+ readonly itemId: string;
29
+ readonly text: string | null;
30
+ };
31
+ /** Pending variant: executed after the retract confirm closes the popover. */
32
+ readonly onRetract?: () => void;
24
33
  readonly preview: string;
25
34
  /** The button that opened the popover (outside-click ignore target). */
26
35
  readonly anchor: HTMLElement;
@@ -30,7 +39,7 @@ export interface PopoverOptions {
30
39
  * the callback owns the command + composer-refill lifecycle (see
31
40
  * runRewindAndFill in index.ts).
32
41
  */
33
- readonly onRewind: (mode: 'chat' | 'both') => void;
42
+ readonly onRewind?: (mode: 'chat' | 'both') => void;
34
43
  }
35
44
  /** Close the current popover, if any. */
36
45
  export declare function closePopover(): void;
@@ -26,6 +26,28 @@ import { type ReactNode } from 'react';
26
26
  import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
27
27
  import type { RewindKey } from './locales.ts';
28
28
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
29
+ /** One portal target: the actions row of a user/steering seat + its durable node. */
30
+ export type PortalTarget = {
31
+ readonly kind: 'durable';
32
+ /** The seat's chat node key (React reconciliation + diff identity). */
33
+ readonly key: string;
34
+ /** The row's actions container (React portal target). */
35
+ readonly container: HTMLElement;
36
+ readonly seq: number;
37
+ readonly time: number;
38
+ readonly preview: string;
39
+ } | {
40
+ readonly kind: 'pending';
41
+ /** `pending:${itemId}` — stable per inbox occurrence. */
42
+ readonly key: string;
43
+ /** The row's actions container (React portal target). */
44
+ readonly container: HTMLElement;
45
+ /** The host inbox occurrence the retract button addresses. */
46
+ readonly itemId: string;
47
+ /** Complete editable text; null when the message contains non-text blocks. */
48
+ readonly text: string | null;
49
+ readonly preview: string;
50
+ };
29
51
  /** Capabilities the session-scoped bridge receives from the plugin apply(). */
30
52
  export interface RewindBridgeDeps {
31
53
  readonly sessionOf: (sessionId: string) => SessionFace | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",