dsh-rewind-plugin 0.3.1 → 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/assets/screenshots/impact-list.png +0 -0
- package/assets/screenshots/mode-popover.png +0 -0
- package/assets/screenshots/rewind-button.png +0 -0
- package/assets/screenshots/rewind-candidates.png +0 -0
- package/lib/client.js +281 -60
- package/lib/index.js +27 -5
- package/lib/types/client/candidates.d.ts +25 -0
- package/lib/types/client/hidden.d.ts +7 -0
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/pending.d.ts +54 -0
- package/lib/types/client/popover.d.ts +12 -3
- package/lib/types/client/portals.d.ts +22 -0
- package/lib/types/rewind.d.ts +35 -0
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -53,6 +53,9 @@ function hasFileImpact(text) {
|
|
|
53
53
|
function isPreviewCommand(command) {
|
|
54
54
|
return (command.args ?? "").includes("preview");
|
|
55
55
|
}
|
|
56
|
+
function isCandidateCommand(command) {
|
|
57
|
+
return (command.args ?? "").includes("__candidates");
|
|
58
|
+
}
|
|
56
59
|
function hiddenSeqsOf(snap) {
|
|
57
60
|
const hidden = /* @__PURE__ */ new Set();
|
|
58
61
|
const spans = [];
|
|
@@ -61,7 +64,7 @@ function hiddenSeqsOf(snap) {
|
|
|
61
64
|
if (node === void 0 || node.kind !== "command") continue;
|
|
62
65
|
const command = node.data;
|
|
63
66
|
if (command.name !== "rewind") continue;
|
|
64
|
-
if (isPreviewCommand(command)) {
|
|
67
|
+
if (isPreviewCommand(command) || isCandidateCommand(command)) {
|
|
65
68
|
hidden.add(command.seq);
|
|
66
69
|
continue;
|
|
67
70
|
}
|
|
@@ -87,6 +90,7 @@ function hiddenSeqsOf(snap) {
|
|
|
87
90
|
|
|
88
91
|
// src/client/candidates.ts
|
|
89
92
|
var PREVIEW_CHARS = 80;
|
|
93
|
+
var DEFAULT_CANDIDATE_LIMIT = 50;
|
|
90
94
|
function messagePreviewOf(message) {
|
|
91
95
|
const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
92
96
|
return text.length <= PREVIEW_CHARS ? text : `${text.slice(0, PREVIEW_CHARS - 1)}\u2026`;
|
|
@@ -97,7 +101,7 @@ function formatCandidateTime(time) {
|
|
|
97
101
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
98
102
|
return `${hh}:${mm}`;
|
|
99
103
|
}
|
|
100
|
-
function rewindCandidatesOf(snap, hidden, limit =
|
|
104
|
+
function rewindCandidatesOf(snap, hidden, limit = DEFAULT_CANDIDATE_LIMIT) {
|
|
101
105
|
const candidates = [];
|
|
102
106
|
for (let i = snap.order.length - 1; i >= 0 && candidates.length < limit; i--) {
|
|
103
107
|
const key = snap.order[i];
|
|
@@ -116,16 +120,30 @@ function rewindCandidatesOf(snap, hidden, limit = 10) {
|
|
|
116
120
|
function rewindCandidatesOfChat(snap) {
|
|
117
121
|
return rewindCandidatesOf(snap, hiddenSeqsOf(snap));
|
|
118
122
|
}
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
var CANDIDATE_LIST_HEADER = "candidates=";
|
|
124
|
+
function rewindCandidatesFromHostText(text) {
|
|
125
|
+
if (!text.startsWith(CANDIDATE_LIST_HEADER)) return [];
|
|
126
|
+
const lines = text.split("\n").slice(1);
|
|
127
|
+
const candidates = [];
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (line === "") continue;
|
|
130
|
+
const parts = line.split(" ");
|
|
131
|
+
if (parts.length !== 3) continue;
|
|
132
|
+
const seq = Number(parts[0]);
|
|
133
|
+
const time = Number(parts[1]);
|
|
134
|
+
const preview = parts[2] ?? "";
|
|
135
|
+
if (!Number.isSafeInteger(seq) || !Number.isFinite(time)) continue;
|
|
136
|
+
candidates.push({ seq, time, preview });
|
|
137
|
+
}
|
|
138
|
+
return candidates;
|
|
139
|
+
}
|
|
140
|
+
function rewindOptionsFromCandidates(candidates, t) {
|
|
141
|
+
return candidates.map((candidate) => ({
|
|
121
142
|
id: String(candidate.seq),
|
|
122
143
|
label: candidate.preview || t("popover.noText"),
|
|
123
144
|
detail: formatCandidateTime(candidate.time)
|
|
124
145
|
}));
|
|
125
146
|
}
|
|
126
|
-
function candidateBySeq(snap, seq) {
|
|
127
|
-
return rewindCandidatesOfChat(snap).find((candidate) => candidate.seq === seq);
|
|
128
|
-
}
|
|
129
147
|
|
|
130
148
|
// src/client/styles.ts
|
|
131
149
|
var CLASS = {
|
|
@@ -449,9 +467,96 @@ function renderImpactStep(root, opts, back, cached) {
|
|
|
449
467
|
impact.textContent = t("popover.impact.failed", { message: "unexpected error" });
|
|
450
468
|
});
|
|
451
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
|
+
}
|
|
452
550
|
function openPopover(opts) {
|
|
453
551
|
closePopover();
|
|
552
|
+
if (opts.retract !== void 0) {
|
|
553
|
+
openRetractPopover(opts);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
454
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 };
|
|
455
560
|
const root = el("div", CLASS.popover);
|
|
456
561
|
root.setAttribute("role", "dialog");
|
|
457
562
|
root.setAttribute("aria-label", t("popover.title"));
|
|
@@ -465,7 +570,7 @@ function openPopover(opts) {
|
|
|
465
570
|
el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
|
|
466
571
|
modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
|
|
467
572
|
closePopover();
|
|
468
|
-
|
|
573
|
+
durableOpts.onRewind("chat");
|
|
469
574
|
})
|
|
470
575
|
];
|
|
471
576
|
if (bothState.state === "noChanges") {
|
|
@@ -492,38 +597,7 @@ function openPopover(opts) {
|
|
|
492
597
|
};
|
|
493
598
|
const renderImpact = () => {
|
|
494
599
|
step = "impact";
|
|
495
|
-
renderImpactStep(root,
|
|
496
|
-
};
|
|
497
|
-
const position = () => {
|
|
498
|
-
const rect = anchor.getBoundingClientRect();
|
|
499
|
-
const gap = 4;
|
|
500
|
-
const height = root.offsetHeight;
|
|
501
|
-
const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
|
|
502
|
-
root.style.top = `${Math.round(top)}px`;
|
|
503
|
-
root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
|
|
504
|
-
};
|
|
505
|
-
renderModes();
|
|
506
|
-
document.body.append(root);
|
|
507
|
-
position();
|
|
508
|
-
focusFirst(root);
|
|
509
|
-
void (async () => {
|
|
510
|
-
const outcome = await previewImpact(session, seq);
|
|
511
|
-
impactOutcome = outcome;
|
|
512
|
-
if (outcome !== null && outcome.kind === "success") {
|
|
513
|
-
bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
|
|
514
|
-
}
|
|
515
|
-
renderModes();
|
|
516
|
-
position();
|
|
517
|
-
})().catch(() => {
|
|
518
|
-
bothState = { state: "hasChanges" };
|
|
519
|
-
renderModes();
|
|
520
|
-
position();
|
|
521
|
-
});
|
|
522
|
-
popoverEl = root;
|
|
523
|
-
const onPointerDown = (event) => {
|
|
524
|
-
const target = event.target;
|
|
525
|
-
if (root.contains(target) || anchor.contains(target)) return;
|
|
526
|
-
closePopover();
|
|
600
|
+
renderImpactStep(root, durableOpts, renderModes, impactOutcome);
|
|
527
601
|
};
|
|
528
602
|
const onKeyDown = (event) => {
|
|
529
603
|
if (event.key === "ArrowDown") {
|
|
@@ -545,20 +619,50 @@ function openPopover(opts) {
|
|
|
545
619
|
else closePopover();
|
|
546
620
|
}
|
|
547
621
|
};
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
+
});
|
|
557
639
|
}
|
|
558
640
|
|
|
559
641
|
// src/client/portals.tsx
|
|
560
642
|
var import_react = require("react");
|
|
561
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
|
|
562
666
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
563
667
|
function userTextAt(session, seq) {
|
|
564
668
|
const snap = session.getSnapshot();
|
|
@@ -601,6 +705,7 @@ var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
|
601
705
|
var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
|
|
602
706
|
var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
|
|
603
707
|
var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
|
|
708
|
+
var PENDING_SEAT_SELECTOR = "[data-pending-steering][data-time-hover-root]";
|
|
604
709
|
function collectTargets(chat, hiddenSeqs) {
|
|
605
710
|
const rows = /* @__PURE__ */ new Map();
|
|
606
711
|
for (const element of document.querySelectorAll(USER_SEAT_SELECTOR)) {
|
|
@@ -617,14 +722,51 @@ function collectTargets(chat, hiddenSeqs) {
|
|
|
617
722
|
const messageRoot = row?.querySelector(ACTIONS_ROOT_SELECTOR);
|
|
618
723
|
const actions = messageRoot?.lastElementChild;
|
|
619
724
|
if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
|
|
620
|
-
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
|
+
});
|
|
621
761
|
}
|
|
622
762
|
return targets;
|
|
623
763
|
}
|
|
624
764
|
function sameTargets(left, right) {
|
|
625
765
|
return left.length === right.length && left.every((target, index) => {
|
|
626
766
|
const other = right[index];
|
|
627
|
-
|
|
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;
|
|
628
770
|
});
|
|
629
771
|
}
|
|
630
772
|
function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
|
|
@@ -644,7 +786,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
644
786
|
setTargets([]);
|
|
645
787
|
return;
|
|
646
788
|
}
|
|
647
|
-
const
|
|
789
|
+
const snapshot = session.getSnapshot();
|
|
790
|
+
const chat = snapshot.chat;
|
|
648
791
|
const hiddenSeqs = hiddenSeqsOf(chat);
|
|
649
792
|
let hiddenCount = 0;
|
|
650
793
|
for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
@@ -666,7 +809,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
666
809
|
`[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
|
|
667
810
|
);
|
|
668
811
|
}
|
|
669
|
-
const next = collectTargets(chat, hiddenSeqs);
|
|
812
|
+
const next = [...collectTargets(chat, hiddenSeqs), ...collectPendingTargets(snapshot)];
|
|
670
813
|
setTargets((current) => sameTargets(current, next) ? current : next);
|
|
671
814
|
};
|
|
672
815
|
const queueRefresh = () => {
|
|
@@ -686,7 +829,16 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
686
829
|
};
|
|
687
830
|
}, [sessionId, sessionOf]);
|
|
688
831
|
return targets.map((target) => (0, import_react_dom.createPortal)(
|
|
689
|
-
/* @__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)(
|
|
690
842
|
RewindButton,
|
|
691
843
|
{
|
|
692
844
|
target,
|
|
@@ -735,6 +887,49 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
|
|
|
735
887
|
}
|
|
736
888
|
);
|
|
737
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
|
+
}
|
|
738
933
|
function createRewindBridge(deps) {
|
|
739
934
|
return function RewindBridge({ sessionId }) {
|
|
740
935
|
return (0, import_react.createElement)(RewindPortals, { sessionId, ...deps });
|
|
@@ -745,8 +940,14 @@ function createRewindBridge(deps) {
|
|
|
745
940
|
var zh = {
|
|
746
941
|
"button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
|
|
747
942
|
"button.title": "\u56DE\u9000",
|
|
943
|
+
"button.retract.aria": "\u56DE\u9000\u5230\u6B64\u63D2\u8BDD\u6D88\u606F",
|
|
944
|
+
"button.retract.title": "\u56DE\u9000",
|
|
748
945
|
"popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
|
|
749
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",
|
|
750
951
|
"popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
|
|
751
952
|
"popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
|
|
752
953
|
"popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
|
|
@@ -766,8 +967,14 @@ var zh = {
|
|
|
766
967
|
var en = {
|
|
767
968
|
"button.aria": "Rewind to this message",
|
|
768
969
|
"button.title": "Rewind",
|
|
970
|
+
"button.retract.aria": "Rewind to this pending message",
|
|
971
|
+
"button.retract.title": "Rewind",
|
|
769
972
|
"popover.title": "Rewind to this message",
|
|
770
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",
|
|
771
978
|
"popover.chat": "Rewind conversation only",
|
|
772
979
|
"popover.chat.hint": "Cut the model context only; workspace files stay untouched",
|
|
773
980
|
"popover.both": "Rewind conversation and code",
|
|
@@ -823,6 +1030,15 @@ function apply(ctx) {
|
|
|
823
1030
|
const chat = chatOf(sessionId);
|
|
824
1031
|
return chat !== void 0 && rewindCandidatesOfChat(chat).length > 0;
|
|
825
1032
|
};
|
|
1033
|
+
const fetchHostCandidates = async (face) => {
|
|
1034
|
+
const known = knownCommandSeqs(face, (node) => isCandidateCommand(node));
|
|
1035
|
+
const result = await face.command("/rewind __candidates");
|
|
1036
|
+
if (!result.ok || result.value?.matched !== true) return void 0;
|
|
1037
|
+
const outcome = await waitForCommand(face, (node) => isCandidateCommand(node) && !known.has(node.seq));
|
|
1038
|
+
if (outcome === null || outcome.kind !== "success" || outcome.text === void 0) return void 0;
|
|
1039
|
+
return rewindCandidatesFromHostText(outcome.text);
|
|
1040
|
+
};
|
|
1041
|
+
const hostCandidatesCache = /* @__PURE__ */ new Map();
|
|
826
1042
|
const composerAnchor = () => {
|
|
827
1043
|
const textarea = composerTextarea();
|
|
828
1044
|
const card = textarea?.closest("[data-composer-card]");
|
|
@@ -837,15 +1053,20 @@ function apply(ctx) {
|
|
|
837
1053
|
available: (session) => hasCandidates(session.sessionId),
|
|
838
1054
|
ui: {
|
|
839
1055
|
kind: "popupSelect",
|
|
840
|
-
options: (session) => {
|
|
841
|
-
const
|
|
842
|
-
|
|
1056
|
+
options: async (session) => {
|
|
1057
|
+
const face = sessionOf(session.sessionId);
|
|
1058
|
+
if (face === void 0) return [];
|
|
1059
|
+
const candidates = await fetchHostCandidates(face);
|
|
1060
|
+
if (candidates !== void 0) hostCandidatesCache.set(session.sessionId, candidates);
|
|
1061
|
+
return candidates === void 0 ? [] : rewindOptionsFromCandidates(candidates, t);
|
|
843
1062
|
},
|
|
844
1063
|
onSelect: (option, session) => {
|
|
845
1064
|
const face = sessionOf(session.sessionId);
|
|
846
|
-
|
|
847
|
-
const candidate =
|
|
848
|
-
|
|
1065
|
+
if (face === void 0) return;
|
|
1066
|
+
const candidate = hostCandidatesCache.get(session.sessionId)?.find(
|
|
1067
|
+
(candidate2) => candidate2.seq === Number(option.id)
|
|
1068
|
+
);
|
|
1069
|
+
if (candidate === void 0) return;
|
|
849
1070
|
openPopover({
|
|
850
1071
|
session: face,
|
|
851
1072
|
seq: candidate.seq,
|
package/lib/index.js
CHANGED
|
@@ -80,6 +80,7 @@ var RewindError = class extends Error {
|
|
|
80
80
|
code;
|
|
81
81
|
};
|
|
82
82
|
var CANDIDATE_PREVIEW_CHARS = 80;
|
|
83
|
+
var DEFAULT_CANDIDATE_LIMIT = 50;
|
|
83
84
|
function markerTurnOf(events) {
|
|
84
85
|
let lastStarted = 0;
|
|
85
86
|
for (const event of events) {
|
|
@@ -92,6 +93,9 @@ function markerTurnOf(events) {
|
|
|
92
93
|
function isUserMessageEvent(event) {
|
|
93
94
|
return event.type === "user/message";
|
|
94
95
|
}
|
|
96
|
+
function isHumanUserMessageEvent(event) {
|
|
97
|
+
return isUserMessageEvent(event) && event.data.source.kind === "user";
|
|
98
|
+
}
|
|
95
99
|
function messagePreview(message) {
|
|
96
100
|
const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
|
|
97
101
|
return text.length <= CANDIDATE_PREVIEW_CHARS ? text : `${text.slice(0, CANDIDATE_PREVIEW_CHARS - 1)}\u2026`;
|
|
@@ -106,13 +110,13 @@ function parseRewindTarget(raw) {
|
|
|
106
110
|
const index = Number(token);
|
|
107
111
|
return Number.isSafeInteger(index) && index >= 1 ? { kind: "index", index } : void 0;
|
|
108
112
|
}
|
|
109
|
-
function listRewindCandidates(events, surface, limit =
|
|
113
|
+
function listRewindCandidates(events, surface, limit = DEFAULT_CANDIDATE_LIMIT) {
|
|
110
114
|
const surfaceIndexes = /* @__PURE__ */ new Map();
|
|
111
115
|
for (let i = 0; i < surface.length; i++) surfaceIndexes.set(surface[i], i);
|
|
112
116
|
const candidates = [];
|
|
113
117
|
for (let i = events.length - 1; i >= 0 && candidates.length < limit; i--) {
|
|
114
118
|
const event = events[i];
|
|
115
|
-
if (!
|
|
119
|
+
if (!isHumanUserMessageEvent(event)) continue;
|
|
116
120
|
if (!surfaceIndexes.has(event.seq)) continue;
|
|
117
121
|
candidates.push({
|
|
118
122
|
seq: event.seq,
|
|
@@ -123,6 +127,14 @@ function listRewindCandidates(events, surface, limit = 10) {
|
|
|
123
127
|
}
|
|
124
128
|
return candidates;
|
|
125
129
|
}
|
|
130
|
+
var CANDIDATE_LIST_HEADER = "candidates=";
|
|
131
|
+
function formatCandidateList(candidates) {
|
|
132
|
+
const lines = [`${CANDIDATE_LIST_HEADER}${candidates.length}`];
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
lines.push(`${candidate.seq} ${candidate.time} ${candidate.preview}`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
126
138
|
function planRewind(events, surface, target) {
|
|
127
139
|
let targetSeq;
|
|
128
140
|
if (target.kind === "seq") {
|
|
@@ -138,10 +150,10 @@ function planRewind(events, surface, target) {
|
|
|
138
150
|
if (targetEvent === void 0) {
|
|
139
151
|
throw new RewindError("not-a-user-message", `no session event at seq ${targetSeq}`);
|
|
140
152
|
}
|
|
141
|
-
if (!
|
|
153
|
+
if (!isHumanUserMessageEvent(targetEvent)) {
|
|
142
154
|
throw new RewindError(
|
|
143
155
|
"not-a-user-message",
|
|
144
|
-
`session event at seq ${targetSeq} is not a user message (${targetEvent.type})`
|
|
156
|
+
`session event at seq ${targetSeq} is not a human user message (${targetEvent.type})`
|
|
145
157
|
);
|
|
146
158
|
}
|
|
147
159
|
const targetIndex = surface.indexOf(targetSeq);
|
|
@@ -641,6 +653,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
|
641
653
|
if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
|
|
642
654
|
}
|
|
643
655
|
}
|
|
656
|
+
function dropPendingSteering(agent) {
|
|
657
|
+
for (const message of [...agent.inbox.nextStep]) {
|
|
658
|
+
agent.inbox.remove(message.id);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
644
661
|
async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
|
|
645
662
|
const { agent } = invocation;
|
|
646
663
|
const sessionId = agent.session.id;
|
|
@@ -650,12 +667,13 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
|
|
|
650
667
|
inflight.add(sessionId);
|
|
651
668
|
try {
|
|
652
669
|
if (agent.status !== "idle") {
|
|
653
|
-
agent.cancel({ kind: "user" });
|
|
670
|
+
agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
654
671
|
const stopped = await waitForAgentIdle(agent, invocation.signal);
|
|
655
672
|
if (!stopped) {
|
|
656
673
|
return { kind: "error", text: t("stopFailed") };
|
|
657
674
|
}
|
|
658
675
|
}
|
|
676
|
+
dropPendingSteering(agent);
|
|
659
677
|
if (invocation.signal.aborted) {
|
|
660
678
|
return { kind: "error", text: t("cancelled") };
|
|
661
679
|
}
|
|
@@ -733,6 +751,10 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
|
733
751
|
const impacts = await store.impactsAfter(session.id, plan.targetSeq);
|
|
734
752
|
return { kind: "success", text: formatPlan(plan, impacts) };
|
|
735
753
|
}
|
|
754
|
+
if (parts[0] === "__candidates") {
|
|
755
|
+
const candidates = listRewindCandidates(session.events, session.surface.nodes);
|
|
756
|
+
return { kind: "success", text: formatCandidateList(candidates) };
|
|
757
|
+
}
|
|
736
758
|
const target = parts[0];
|
|
737
759
|
const mode = parts[1];
|
|
738
760
|
if (mode !== void 0 && mode !== "chat" && mode !== "both") {
|
|
@@ -15,6 +15,14 @@ import type { RewindKey } from './locales.ts';
|
|
|
15
15
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
16
16
|
/** Preview length cap for candidate rows (matches the host's candidate list). */
|
|
17
17
|
export declare const PREVIEW_CHARS = 80;
|
|
18
|
+
/**
|
|
19
|
+
* Default cap on how many user messages the rewind picker lists (newest kept).
|
|
20
|
+
*
|
|
21
|
+
* A fixed 10 made long sessions look "incomplete" (only the newest 10 shown).
|
|
22
|
+
* 50 keeps the picker scrollable/searchable via the popupSelect shell while
|
|
23
|
+
* covering far longer sessions; callers can still pass an explicit `limit`.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_CANDIDATE_LIMIT = 50;
|
|
18
26
|
/** One selectable rewind target. */
|
|
19
27
|
export interface RewindCandidate {
|
|
20
28
|
/** Absolute log seq of the `user/message` event. */
|
|
@@ -73,4 +81,21 @@ export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandi
|
|
|
73
81
|
export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
|
|
74
82
|
/** Resolve one candidate by log seq (the mode popover's re-entry after a pick). */
|
|
75
83
|
export declare function candidateBySeq(snap: CandidateChat, seq: number): RewindCandidate | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* Parse the host's candidate-list encoding (see `formatCandidateList` in
|
|
86
|
+
* src/rewind.ts) into typed candidates. Malformed lines are skipped; a
|
|
87
|
+
* missing/zero header yields an empty list.
|
|
88
|
+
*/
|
|
89
|
+
export declare function rewindCandidatesFromHostText(text: string): RewindCandidate[];
|
|
90
|
+
/**
|
|
91
|
+
* Map typed candidates to popupSelect rows (the host-derived path). The
|
|
92
|
+
* popupSelect sources its options from the FULL host surface via the
|
|
93
|
+
* `__candidates` channel instead of the windowed chat snapshot.
|
|
94
|
+
*/
|
|
95
|
+
export declare function rewindOptionsFromCandidates(candidates: readonly RewindCandidate[], t: Translate): SelectOption[];
|
|
96
|
+
/**
|
|
97
|
+
* Parse the host's candidate-list encoding (see `formatCandidateList` in
|
|
98
|
+
* src/rewind.ts) into popupSelect rows.
|
|
99
|
+
*/
|
|
100
|
+
export declare function rewindOptionsFromHostText(text: string, t: Translate): SelectOption[];
|
|
76
101
|
export {};
|
|
@@ -38,6 +38,13 @@ export declare function isExecutedRewindCommand(node: CommandNode, seq: number):
|
|
|
38
38
|
* always-show so a working option is never hidden on a failed probe.
|
|
39
39
|
*/
|
|
40
40
|
export declare function hasFileImpact(text: string | undefined): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when a `/rewind` command node is the internal candidate-list probe
|
|
43
|
+
* (`/rewind __candidates`) the popupSelect runs to fetch the FULL candidate
|
|
44
|
+
* list from the host. Like previews, its flow node never surfaces in the
|
|
45
|
+
* transcript — it only feeds the popup — so it is hidden in every state.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isCandidateCommand(command: CommandNode): boolean;
|
|
41
48
|
/**
|
|
42
49
|
* Anchor seqs that must be hidden from the rendered transcript so the user
|
|
43
50
|
* sees the conversation as the agent sees it: every impact-preview flow node
|
|
@@ -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
|
-
|
|
23
|
-
readonly
|
|
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
|
|
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/lib/types/rewind.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface RewindPlan {
|
|
|
60
60
|
}
|
|
61
61
|
/** Preview length cap for candidate listings. */
|
|
62
62
|
export declare const CANDIDATE_PREVIEW_CHARS = 80;
|
|
63
|
+
/**
|
|
64
|
+
* Default cap on how many user messages a candidate listing returns (newest
|
|
65
|
+
* kept). Raised from 10 so long sessions don't look incomplete; callers can
|
|
66
|
+
* still pass an explicit `limit`.
|
|
67
|
+
*/
|
|
68
|
+
export declare const DEFAULT_CANDIDATE_LIMIT = 50;
|
|
63
69
|
/**
|
|
64
70
|
* Turn number for the rewind marker.
|
|
65
71
|
*
|
|
@@ -85,6 +91,18 @@ export declare const CANDIDATE_PREVIEW_CHARS = 80;
|
|
|
85
91
|
export declare function markerTurnOf(events: readonly SessionEvent[]): number;
|
|
86
92
|
/** Narrow an event to a user message. */
|
|
87
93
|
export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
|
|
94
|
+
/**
|
|
95
|
+
* True for a HUMAN user message event — one whose `source.kind` is `'user'`.
|
|
96
|
+
*
|
|
97
|
+
* The surface can carry `user/message` events whose source is NOT the user:
|
|
98
|
+
* plugin/system context injection (including compaction checkpoints) and
|
|
99
|
+
* tool-result backfill all arrive as `user/message` with a non-`'user'`
|
|
100
|
+
* source, and the client renders those as `context` nodes, never as a user
|
|
101
|
+
* bubble. Only genuine user messages (and user steering during a running
|
|
102
|
+
* turn, which keeps `source.kind: 'user'`) are valid rewind targets — a
|
|
103
|
+
* rewind boundary must land on a human prompt, not on injected context.
|
|
104
|
+
*/
|
|
105
|
+
export declare function isHumanUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
|
|
88
106
|
/** Join the text blocks of a message into one plain string. */
|
|
89
107
|
export declare function messagePreview(message: UserMessage): string;
|
|
90
108
|
/**
|
|
@@ -104,6 +122,23 @@ export declare function parseRewindTarget(raw: string): RewindTarget | undefined
|
|
|
104
122
|
* @returns candidates numbered 1..N by recency.
|
|
105
123
|
*/
|
|
106
124
|
export declare function listRewindCandidates(events: readonly SessionEvent[], surface: readonly number[], limit?: number): RewindCandidate[];
|
|
125
|
+
/** Header line of the machine-readable candidate list (locale-independent). */
|
|
126
|
+
export declare const CANDIDATE_LIST_HEADER = "candidates=";
|
|
127
|
+
/**
|
|
128
|
+
* Encode a candidate list as the host→client machine channel (the same
|
|
129
|
+
* trailer pattern `formatPlan` uses for `impact=`). The client popupSelect
|
|
130
|
+
* parses this instead of reading the windowed chat snapshot, so the candidate
|
|
131
|
+
* list reflects the FULL host surface — not just the already-loaded history.
|
|
132
|
+
*
|
|
133
|
+
* Lines (each preview is already whitespace-collapsed and tab-free by
|
|
134
|
+
* `messagePreview`):
|
|
135
|
+
* candidates=<n>
|
|
136
|
+
* <seq>\t<time>\t<preview>
|
|
137
|
+
* … (one line per candidate, newest first, matching `listRewindCandidates`)
|
|
138
|
+
*
|
|
139
|
+
* A list with no candidates is just `candidates=0`.
|
|
140
|
+
*/
|
|
141
|
+
export declare function formatCandidateList(candidates: readonly RewindCandidate[]): string;
|
|
107
142
|
/**
|
|
108
143
|
* Resolve a target against the session log and surface into a validated plan.
|
|
109
144
|
* @param events - the full session event log.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.3.
|
|
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",
|