dsh-rewind-plugin 0.14.0 → 0.15.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -55,6 +55,31 @@ function messageTextAt(chat, seq) {
55
55
  }
56
56
  return void 0;
57
57
  }
58
+ function messageAttachmentsAt(chat, seq) {
59
+ if (chat === void 0) return void 0;
60
+ for (const key of chat.order) {
61
+ const node = chat.nodes.get(key);
62
+ if (node === void 0 || node.kind !== "user" && node.kind !== "steering") continue;
63
+ const data = node.data;
64
+ if (data.seq !== seq) continue;
65
+ const images = [];
66
+ for (const block of data.content ?? []) {
67
+ if (block.type !== "image") continue;
68
+ const attachment = block.attachment;
69
+ if (typeof attachment?.attachmentId !== "string" || typeof attachment.mediaType !== "string") continue;
70
+ images.push({
71
+ attachmentId: attachment.attachmentId,
72
+ mediaType: attachment.mediaType,
73
+ bytes: typeof attachment.bytes === "number" ? attachment.bytes : void 0,
74
+ width: typeof attachment.width === "number" ? attachment.width : void 0,
75
+ height: typeof attachment.height === "number" ? attachment.height : void 0,
76
+ name: typeof attachment.name === "string" ? attachment.name : void 0
77
+ });
78
+ }
79
+ return images;
80
+ }
81
+ return void 0;
82
+ }
58
83
  function targetSeqOfArgs(args) {
59
84
  if (args === void 0 || args === null) return void 0;
60
85
  const match = args.match(/@(\d+)/);
@@ -259,8 +284,9 @@ var STYLE = `
259
284
  display: inline-flex;
260
285
  align-items: center;
261
286
  justify-content: center;
262
- width: 28px;
263
- height: 28px;
287
+ /* The neighbouring action buttons scale by this delta too. */
288
+ width: calc(28px + var(--dsh-content-font-delta, 0px));
289
+ height: calc(28px + var(--dsh-content-font-delta, 0px));
264
290
  padding: 6px;
265
291
  border: none;
266
292
  border-radius: var(--dsw-radius-sm, 8px);
@@ -268,6 +294,10 @@ var STYLE = `
268
294
  color: var(--dsw-alias-label-tertiary);
269
295
  cursor: pointer;
270
296
  }
297
+ .dsh-rewind-btn svg {
298
+ width: calc(15px + var(--dsh-content-font-delta, 0px));
299
+ height: calc(15px + var(--dsh-content-font-delta, 0px));
300
+ }
271
301
  .dsh-rewind-btn:hover {
272
302
  background: var(--dsw-alias-interactive-bg-hover);
273
303
  color: var(--dsw-alias-label-secondary);
@@ -803,34 +833,41 @@ var import_react_dom = require("react-dom");
803
833
  // src/client/pending.ts
804
834
  var QUEUE_PREVIEW_CHARS = 200;
805
835
  function textOf(content) {
806
- if (!content.every((block) => block.type === "text")) return null;
807
- return content.map((block) => block.text ?? "").join("");
836
+ const texts = content.filter((block) => block.type === "text");
837
+ if (texts.length === 0) return null;
838
+ return texts.map((block) => block.text ?? "").join("");
839
+ }
840
+ function attachmentsCountOf(content) {
841
+ return content.filter((block) => block.type !== "text").length;
808
842
  }
809
- function previewOf(content) {
810
- const flat = content.filter((block) => block.type !== "image" && block.type !== "file").map((block) => block.type === "text" ? block.text ?? "" : `[${block.type}]`).join(" ").replace(/\s+/g, " ").trim();
811
- const chars = Array.from(flat);
812
- return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join("")}\u2026` : flat;
843
+ function previewOf(text) {
844
+ const chars = Array.from((text ?? "").replace(/\s+/g, " ").trim());
845
+ return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join("")}\u2026` : chars.join("");
813
846
  }
814
847
  function steeringItemsOf(nextStep) {
815
- return (nextStep ?? []).filter((item) => item.source?.kind === "user").map((item) => ({
816
- id: item.id,
817
- text: textOf(item.content),
818
- preview: previewOf(item.content)
819
- }));
848
+ return (nextStep ?? []).filter((item) => item.source?.kind === "user").map((item) => {
849
+ const text = textOf(item.content);
850
+ return {
851
+ id: item.id,
852
+ text,
853
+ attachments: attachmentsCountOf(item.content),
854
+ preview: previewOf(text)
855
+ };
856
+ });
820
857
  }
821
858
  function matchPendingRows(rows, steering) {
822
859
  const matched = [];
823
860
  for (let i = 0; i < rows.length; i++) {
824
861
  const row = rows[i];
825
862
  const item = steering[i];
826
- if (item !== void 0 && row.text === (item.text ?? "")) {
827
- matched.push(item.id);
828
- } else {
829
- matched.push(null);
830
- }
863
+ matched.push(item !== void 0 && matches2(item, row) ? item.id : null);
831
864
  }
832
865
  return matched;
833
866
  }
867
+ function matches2(item, row) {
868
+ if (item.attachments > 0) return row.attachments === item.attachments;
869
+ return row.text === (item.text ?? "");
870
+ }
834
871
  function retractSpan(steering, targetId) {
835
872
  const index = steering.findIndex((item) => item.id === targetId);
836
873
  if (index === -1) return [];
@@ -883,7 +920,7 @@ function userNodeOf(chat, key) {
883
920
  if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
884
921
  return node.data;
885
922
  }
886
- async function runRewindAndFill(session, seq, mode, isMainViewSession, chatOf, watchChat, setComposerText) {
923
+ async function runRewindAndFill(session, seq, mode, isMainViewSession, chatOf, watchChat, setComposerText, restoreImages) {
887
924
  const known = knownCommandSeqs(session, chatOf, (node) => isExecutedRewindCommand(node, seq));
888
925
  let result;
889
926
  try {
@@ -919,12 +956,22 @@ async function runRewindAndFill(session, seq, mode, isMainViewSession, chatOf, w
919
956
  return;
920
957
  }
921
958
  let text;
959
+ let images;
922
960
  try {
923
- text = messageTextAt(chatOf(session), seq);
961
+ const chat = chatOf(session);
962
+ text = messageTextAt(chat, seq);
963
+ images = messageAttachmentsAt(chat, seq);
924
964
  } catch (error) {
925
- rewindLog.warn("refill", `reading target text for @${seq} threw`, error);
965
+ rewindLog.warn("refill", `reading target content for @${seq} threw`, error);
926
966
  return;
927
967
  }
968
+ if (restoreImages !== void 0 && images !== void 0 && images.length > 0) {
969
+ try {
970
+ await restoreImages(session.sessionId, images);
971
+ } catch (error) {
972
+ rewindLog.warn("refill", `image restore for @${seq} threw`, error);
973
+ }
974
+ }
928
975
  if (text === void 0 || text === "") {
929
976
  return;
930
977
  }
@@ -968,6 +1015,7 @@ function resolveSeatAnchorSeq(element, chat) {
968
1015
  return node?.anchorSeq;
969
1016
  }
970
1017
  var PENDING_SEAT_SELECTOR = "[data-pending-steering]";
1018
+ var MESSAGE_ATTACHMENT_SELECTOR = "[data-message-attachments]";
971
1019
  function actionsContainerOf(row) {
972
1020
  const buttons = Array.from(row?.querySelectorAll("button") ?? []);
973
1021
  const lastButton = buttons.filter((button) => !button.classList.contains(CLASS.button)).at(-1);
@@ -1041,16 +1089,16 @@ var INBOX_PROJECTION = "inbox";
1041
1089
  function bubbleTextOf(row) {
1042
1090
  const clone = row.cloneNode(true);
1043
1091
  clone.lastElementChild?.remove();
1044
- return clone.textContent ?? "";
1092
+ const originals = row.querySelectorAll(MESSAGE_ATTACHMENT_SELECTOR);
1093
+ const copies = clone.querySelectorAll(MESSAGE_ATTACHMENT_SELECTOR);
1094
+ for (let index = 0; index < copies.length; index++) copies[index]?.remove();
1095
+ return { text: clone.textContent ?? "", attachments: originals.length };
1045
1096
  }
1046
1097
  function collectPendingTargets(snapshot, steering) {
1047
1098
  if (snapshot.subagent !== null) return [];
1048
1099
  if (steering.length === 0) return [];
1049
1100
  const rows = Array.from(document.querySelectorAll(PENDING_SEAT_SELECTOR));
1050
- const matched = matchPendingRows(
1051
- rows.map((row) => ({ text: bubbleTextOf(row) })),
1052
- steering
1053
- );
1101
+ const matched = matchPendingRows(rows.map(bubbleTextOf), steering);
1054
1102
  const targets = [];
1055
1103
  for (let i = 0; i < matched.length; i++) {
1056
1104
  const itemId = matched[i];
@@ -1079,7 +1127,7 @@ function sameTargets(left, right) {
1079
1127
  return other.kind === "pending" && target.itemId === other.itemId;
1080
1128
  });
1081
1129
  }
1082
- function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchChat, t, subscribeLocale, setComposerText }) {
1130
+ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchChat, t, subscribeLocale, setComposerText, restoreImages }) {
1083
1131
  const [targets, setTargets] = (0, import_react.useState)([]);
1084
1132
  const hidden = (0, import_react.useRef)(/* @__PURE__ */ new WeakSet());
1085
1133
  const [, forceRender] = (0, import_react.useReducer)((count) => count + 1, 0);
@@ -1166,6 +1214,7 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
1166
1214
  watchChat,
1167
1215
  isMainViewSession,
1168
1216
  setComposerText,
1217
+ restoreImages,
1169
1218
  t
1170
1219
  },
1171
1220
  target.key
@@ -1174,7 +1223,7 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
1174
1223
  target.key
1175
1224
  ));
1176
1225
  }
1177
- function RewindButton({ target, sessionId, sessionOf, chatOf, watchChat, isMainViewSession, setComposerText, t }) {
1226
+ function RewindButton({ target, sessionId, sessionOf, chatOf, watchChat, isMainViewSession, setComposerText, restoreImages, t }) {
1178
1227
  const onClick = (event) => {
1179
1228
  event.stopPropagation();
1180
1229
  const session = sessionOf(sessionId);
@@ -1194,7 +1243,7 @@ function RewindButton({ target, sessionId, sessionOf, chatOf, watchChat, isMainV
1194
1243
  anchor: event.currentTarget,
1195
1244
  t,
1196
1245
  onRewind: (mode) => {
1197
- void runRewindAndFill(session, node.seq, mode, isMainViewSession, chatOf, watchChat, setComposerText);
1246
+ void runRewindAndFill(session, node.seq, mode, isMainViewSession, chatOf, watchChat, setComposerText, restoreImages);
1198
1247
  }
1199
1248
  });
1200
1249
  };
@@ -1271,9 +1320,9 @@ function createRewindBridge(deps) {
1271
1320
  }
1272
1321
 
1273
1322
  // src/client/build-info.ts
1274
- var PLUGIN_VERSION = true ? "0.14.0" : "dev";
1323
+ var PLUGIN_VERSION = true ? "0.15.0-beta.2" : "dev";
1275
1324
  var PLUGIN_PACKAGE = "dsh-rewind-plugin";
1276
- var BUILD_HASH = true ? "09b74c90" : "dev";
1325
+ var BUILD_HASH = true ? "0582c3a6" : "dev";
1277
1326
 
1278
1327
  // src/client/locales.ts
1279
1328
  var zh = {
@@ -1490,6 +1539,48 @@ function apply(ctx) {
1490
1539
  }
1491
1540
  };
1492
1541
  };
1542
+ const IMAGE_MEDIA_EXTENSION = {
1543
+ "image/png": "png",
1544
+ "image/jpeg": "jpg",
1545
+ "image/webp": "webp",
1546
+ "image/gif": "gif"
1547
+ };
1548
+ const restoreImages = async (sessionId, images) => {
1549
+ if (images.length === 0) return;
1550
+ const ui = uiConversation();
1551
+ const conversation = ctx.get("conversation");
1552
+ const scope = ctx.sessions.scope?.(sessionId);
1553
+ if (ui?.imageUrl === void 0 || conversation?.input === void 0 || conversation.createDrafts === void 0 || scope === void 0) {
1554
+ rewindLog.warn("refill", "image restore unavailable (conversation services not ready)");
1555
+ return;
1556
+ }
1557
+ const files = [];
1558
+ for (const [index, ref] of images.entries()) {
1559
+ const extension = IMAGE_MEDIA_EXTENSION[ref.mediaType];
1560
+ if (extension === void 0) {
1561
+ rewindLog.warn("refill", `skipping image with unsupported type ${ref.mediaType}`);
1562
+ continue;
1563
+ }
1564
+ try {
1565
+ const url = await ui.imageUrl(sessionId, ref);
1566
+ const blob = await (await fetch(url)).blob();
1567
+ files.push(new File([blob], ref.name ?? `image-${index + 1}.${extension}`, { type: ref.mediaType }));
1568
+ } catch (error) {
1569
+ rewindLog.warn("refill", `image fetch threw for ${ref.attachmentId}`, error);
1570
+ }
1571
+ }
1572
+ if (files.length === 0) return;
1573
+ try {
1574
+ const drafts = conversation.createDrafts(sessionId, files);
1575
+ const admitted = conversation.input.for(scope).addAttachments(drafts.map((draft) => draft.id));
1576
+ if (!admitted) {
1577
+ rewindLog.warn("refill", `composer refused ${drafts.length} restored image(s) (busy)`);
1578
+ conversation.releaseDraftAttachments?.(drafts);
1579
+ }
1580
+ } catch (error) {
1581
+ rewindLog.warn("refill", "image restore threw", error);
1582
+ }
1583
+ };
1493
1584
  const setComposerText = (sessionId, text) => {
1494
1585
  try {
1495
1586
  return writeComposer(text, composerFacade(sessionId));
@@ -1525,7 +1616,7 @@ function apply(ctx) {
1525
1616
  id: "dsh-rewind-portals",
1526
1617
  order: 1e3
1527
1618
  },
1528
- createRewindBridge({ sessionOf, chatOf, isMainViewSession, watchChat, setComposerText, t, subscribeLocale })
1619
+ createRewindBridge({ sessionOf, chatOf, isMainViewSession, watchChat, setComposerText, restoreImages, t, subscribeLocale })
1529
1620
  ));
1530
1621
  try {
1531
1622
  const { store, form } = cleanupForm(
@@ -1599,7 +1690,7 @@ function apply(ctx) {
1599
1690
  anchor: composerAnchor(),
1600
1691
  t,
1601
1692
  onRewind: (mode) => {
1602
- void runRewindAndFill(face, candidate.seq, mode, isMainViewSession, chatOf, watchChat, setComposerText);
1693
+ void runRewindAndFill(face, candidate.seq, mode, isMainViewSession, chatOf, watchChat, setComposerText, restoreImages);
1603
1694
  }
1604
1695
  });
1605
1696
  }
package/lib/index.js CHANGED
@@ -24,6 +24,8 @@ var en = {
24
24
  "skipReason.oversized": "over the {limit} backup limit, so the recorded state predates untracked edits",
25
25
  "uncovered.header": "{count} tracked file(s) exceed the {limit} backup limit and are not covered:",
26
26
  "error.invalidTarget": 'Cannot parse target "{raw}" (expected <index> or @<seq>)',
27
+ "sep.list": ", ",
28
+ "sep.clause": "; ",
27
29
  "failures.suffix": "; {count} file(s) failed to restore: {list}",
28
30
  "failures.item": "{path} ({message})",
29
31
  "inflight": "A rewind is already running for this session; please wait.",
@@ -77,6 +79,8 @@ var zh = {
77
79
  "skipReason.oversized": "\u5DF2\u8D85\u8FC7 {limit} \u7684\u5907\u4EFD\u4E0A\u9650\uFF0C\u8BB0\u5F55\u7684\u72B6\u6001\u65E9\u4E8E\u672A\u88AB\u8DDF\u8E2A\u7684\u4FEE\u6539",
78
80
  "uncovered.header": "{count} \u4E2A\u5DF2\u8DDF\u8E2A\u6587\u4EF6\u8D85\u8FC7 {limit} \u7684\u5907\u4EFD\u4E0A\u9650\uFF0C\u672A\u7EB3\u5165\u8BB0\u5F55\uFF1A",
79
81
  "error.invalidTarget": '\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "{raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09',
82
+ "sep.list": "\u3001",
83
+ "sep.clause": "\uFF1B",
80
84
  "failures.suffix": "\uFF1B{count} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A{list}",
81
85
  "failures.item": "{path}\uFF08{message}\uFF09",
82
86
  "inflight": "\u8BE5\u4F1A\u8BDD\u5DF2\u6709\u4E00\u4E2A\u56DE\u9000\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7A0D\u5019\u3002",
@@ -149,15 +153,21 @@ function messagePreview(message) {
149
153
  const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
150
154
  return text.length <= CANDIDATE_PREVIEW_CHARS ? text : `${text.slice(0, CANDIDATE_PREVIEW_CHARS - 1)}\u2026`;
151
155
  }
156
+ var SEQ_TOKEN = /^@(\d+)$/;
157
+ var INDEX_TOKEN = /^(\d+)$/;
152
158
  function parseRewindTarget(raw) {
153
159
  const token = raw.trim();
154
- if (token === "") return void 0;
155
- if (token.startsWith("@")) {
156
- const seq = Number(token.slice(1));
157
- return Number.isSafeInteger(seq) && seq >= 0 ? { kind: "seq", seq } : void 0;
160
+ const seq = SEQ_TOKEN.exec(token);
161
+ if (seq !== null) {
162
+ const value = Number(seq[1] ?? "");
163
+ return Number.isSafeInteger(value) ? { kind: "seq", seq: value } : void 0;
158
164
  }
159
- const index = Number(token);
160
- return Number.isSafeInteger(index) && index >= 1 ? { kind: "index", index } : void 0;
165
+ const index = INDEX_TOKEN.exec(token);
166
+ if (index !== null) {
167
+ const value = Number(index[1] ?? "");
168
+ return Number.isSafeInteger(value) && value >= 1 ? { kind: "index", index: value } : void 0;
169
+ }
170
+ return void 0;
161
171
  }
162
172
  function listRewindCandidates(events, surface, limit = DEFAULT_CANDIDATE_LIMIT) {
163
173
  const surfaceIndexes = /* @__PURE__ */ new Map();
@@ -2517,7 +2527,7 @@ function renderFailures(failed) {
2517
2527
  if (failed.length === 0) return "";
2518
2528
  return t("failures.suffix", {
2519
2529
  count: failed.length,
2520
- list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join("\u3001")
2530
+ list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join(t("sep.list"))
2521
2531
  });
2522
2532
  }
2523
2533
  function renderSkipped(outcome, uncovered, maxFileBytes) {
@@ -2620,7 +2630,7 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
2620
2630
  } catch (error) {
2621
2631
  if (!(error instanceof UnknownStoreVersionError)) throw error;
2622
2632
  const version = await store.readStoreVersion(agent.session.id);
2623
- restore = `\uFF1B${t("storeUnsupported", { version: version ?? error.version })}`;
2633
+ restore = `${t("sep.clause")}${t("storeUnsupported", { version: version ?? error.version })}`;
2624
2634
  }
2625
2635
  if (outcome !== void 0) {
2626
2636
  await syncRestoreObservations(ctx, fs, agent, outcome);
@@ -2633,7 +2643,7 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
2633
2643
  const skippedLines = renderSkipped(outcome, capped, store.fileCapBytes);
2634
2644
  if (skippedLines !== "") parts.push(skippedLines);
2635
2645
  }
2636
- restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
2646
+ restore = parts.length > 0 ? `${t("sep.clause")}${parts.join(t("sep.list"))}` : t("noRestorable");
2637
2647
  restore += renderFailures(outcome.failed);
2638
2648
  }
2639
2649
  }
@@ -74,12 +74,6 @@ export declare function formatCandidateTime(time: number): string;
74
74
  export declare function rewindCandidatesOf(snap: CandidateChat, hidden: ReadonlySet<number>, limit?: number): RewindCandidate[];
75
75
  /** The candidates of a live chat snapshot, withdrawn rows already excluded. */
76
76
  export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandidate[];
77
- /**
78
- * Map the candidates to popupSelect rows: the message preview as the row
79
- * label (left) and the clock time as the detail (right) — the shell's native
80
- * label/detail flex layout, with no recency numbers.
81
- */
82
- export declare function rewindOptionsOf(snap: CandidateChat, t: Translate): SelectOption[];
83
77
  /**
84
78
  * Parse the host's candidate-list encoding (see `formatCandidateList` in
85
79
  * src/rewind.ts) into typed candidates. Malformed lines are skipped; a
@@ -92,9 +86,4 @@ export declare function rewindCandidatesFromHostText(text: string): RewindCandid
92
86
  * `__candidates` channel instead of the windowed chat snapshot.
93
87
  */
94
88
  export declare function rewindOptionsFromCandidates(candidates: readonly RewindCandidate[], t: Translate): SelectOption[];
95
- /**
96
- * Parse the host's candidate-list encoding (see `formatCandidateList` in
97
- * src/rewind.ts) into popupSelect rows.
98
- */
99
- export declare function rewindOptionsFromHostText(text: string, t: Translate): SelectOption[];
100
89
  export {};
@@ -60,11 +60,42 @@ export declare function chatSnapshotOf(chatView: {
60
60
  * filling the composer after a withdraw. Accepts BOTH `user` and `steering`
61
61
  * nodes: a plan-mode (`/plan <text>`) input is delivered through the agent
62
62
  * inbox next-step and claimed, so it renders as `steering`, and its text must
63
- * still return to the composer (`portals.tsx` `runRewindAndFill`) — the old
64
- * `user`-only read silently left it empty. State absent → undefined; a message
65
- * with no text blocks → ''. Same text-blocks join the candidate side uses.
63
+ * still return to the composer (`portals.tsx` `runRewindAndFill`). State absent
64
+ * → undefined; a message with no text blocks → ''. Same text-blocks join the
65
+ * candidate side uses.
66
66
  */
67
67
  export declare function messageTextAt(chat: HiddenChat | undefined, seq: number): string | undefined;
68
+ /**
69
+ * A durable image reference carried by an `image` content block in the chat
70
+ * snapshot, read structurally (the harness's `ImageAttachmentRef` from
71
+ * dsh-attachment, without importing it, so the plugin survives harness
72
+ * drift). Fields beyond `attachmentId`/`mediaType` pass through to the
73
+ * durable image URL loader unchanged.
74
+ */
75
+ export interface MessageImageRef {
76
+ readonly attachmentId: string;
77
+ readonly mediaType: string;
78
+ readonly bytes?: number;
79
+ readonly width?: number;
80
+ readonly height?: number;
81
+ readonly name?: string;
82
+ }
83
+ /**
84
+ * The durable image references of the human message at `seq` in the chat
85
+ * snapshot, in message order. The composer-refill counterpart of
86
+ * `messageTextAt` (see `portals.tsx` `runRewindAndFill`): rewinding an
87
+ * image-bearing message restores its pictures into the composer so the
88
+ * withdraw does not silently drop them.
89
+ *
90
+ * Reads the same node kinds as `messageTextAt` (`user` and `steering`).
91
+ * Blocks tolerate malformed or missing references (skipped, never a throw),
92
+ * and `image/offload` projections leave the durable reference in place —
93
+ * which is all the URL loader needs. `file` blocks are intentionally
94
+ * excluded: the harness exposes no client-side file-bytes loader (file
95
+ * uploads go one-way), so a file is not restorable.
96
+ * State absent → undefined; a message without images → [].
97
+ */
98
+ export declare function messageAttachmentsAt(chat: HiddenChat | undefined, seq: number): readonly MessageImageRef[] | undefined;
68
99
  /**
69
100
  * Extract the rewind target seq from a `/rewind` command's structured `args`
70
101
  * (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
@@ -98,25 +129,16 @@ export declare function hasFileImpact(text: string | undefined): boolean;
98
129
  */
99
130
  export declare function isCandidateCommand(command: CommandNode): boolean;
100
131
  /**
101
- * Anchor seqs that must be hidden from the rendered transcript so the user
102
- * sees the conversation as the agent sees it: every impact-preview flow node
103
- * (pending, succeeded, or errored — it only exists to feed the popover) and
104
- * every SUCCESSFUL executed `/rewind` command row, plus every message
105
- * withdrawn by a rewind — the target message itself, everything after it, and
106
- * the (unrendered) marker.
107
- *
108
- * Each executed rewind cuts ONE span `[target, marker]`: the target message
109
- * and everything after it, up to the marker appended at rewind time. Spans are
110
- * kept SEPARATE (never collapsed to a single `[min target, max marker]`)
111
- * because a later rewind to a LATER point leaves a visible gap of new traffic
112
- * between the earlier marker and the later target — collapsing the spans would
113
- * hide that still-on-surface gap. Endpoints come from the command nodes:
114
- * `sourceEventSeq` is the marker's log seq, and the outcome text carries the
115
- * target seq.
132
+ * Anchor seqs that must be hidden from the rendered transcript so the user sees
133
+ * the conversation as the agent sees it: every impact-preview flow node (it only
134
+ * exists to feed the popover) and every SUCCESSFUL executed `/rewind` command
135
+ * row, plus every message a rewind withdrew — the target, everything after it,
136
+ * and the (unrendered) marker.
116
137
  *
117
- * The spans are coalesced into disjoint ascending ranges before the membership
118
- * test, so a node costs one binary search instead of a scan of every rewind's
119
- * range: that keeps the walk linear in the nodes rather than in nodes ×
120
- * rewinds, on a pass that runs for every chat mutation.
138
+ * Each executed rewind cuts ONE span `[target, marker]`, and callers must keep
139
+ * the spans SEPARATE: collapsing them to `[min target, max marker]` would hide a
140
+ * still-on-surface gap of new traffic between an earlier marker and a later
141
+ * target. Endpoints come from the command nodes (`sourceEventSeq` is the
142
+ * marker's log seq; the outcome text carries the target seq).
121
143
  */
122
144
  export declare function hiddenSeqsOf(snap: HiddenChat): Set<number>;
@@ -1,28 +1,16 @@
1
1
  /**
2
2
  * dsh-rewind client half: the `/rewind` command decoration, the locale
3
3
  * registration, and the session-scoped portal bridge that renders the
4
- * per-message ↶ rewind button (see
5
- * `portals.tsx` for the button itself).
4
+ * per-message ↶ rewind button (`portals.tsx` owns the button and the popover).
6
5
  *
7
- * The button is NOT injected by hand into the DOM anymore: the plugin
8
- * registers a bridge into the harness's `conversation.session.header.actions`
9
- * list slot, and that bridge portals a React button into every user message's
10
- * IconActions row — the same rendering family as the copy button (a React
11
- * child of the actions row), without touching any harness source. The
12
- * registration is typed structurally (see `SlotsLike` in portals.tsx), so the
13
- * plugin never imports conversation UI types and survives harness version
14
- * drift.
15
- *
16
- * The text-driven flow is the harness's STANDARD command decoration
17
- * (`ctx.commandUi.decorate`): a bare `/rewind` (or its alias `/undo`) —
18
- * picked from the slash-menu completion, or typed in full and Entered —
19
- * opens the harness's own popupSelect shell (search, ↑↓/Enter, Esc) listing
20
- * the rewind candidates instead of executing the command. Picking one
21
- * continues the SAME flow as the ↶ button: the mode popover, both-impact
22
- * confirmation, execution, row hiding and the composer refill
23
- * (`runRewindAndFill`). The parameterized forms (`/rewind @<seq> chat|both`,
24
- * `/rewind preview …`) stay internal channels the ↶ button and the popover
25
- * drive through `session.command`.
6
+ * The button registers through the harness's
7
+ * `conversation.session.header.actions` slot — a typed structural registration,
8
+ * so the plugin imports no conversation UI types and survives harness drift.
9
+ * The command decoration (`ctx.commandUi.decorate`) makes a bare `/rewind` (or
10
+ * its alias `/undo`) open the harness's own popupSelect instead of executing;
11
+ * the parameterized `@<seq>` / `preview` forms stay internal channels the
12
+ * button and the popover drive. The exported contract is specified in
13
+ * `docs/contract/client-contract.md`.
26
14
  *
27
15
  * @module dsh-rewind/client
28
16
  */
@@ -1,25 +1,17 @@
1
1
  /**
2
- * dsh-rewind client logger: a single, namespaced, level-filtered logging
3
- * channel for every browser-side diagnostic in this plugin.
2
+ * dsh-rewind client logger: one namespaced, level-filtered channel for every
3
+ * browser-side diagnostic in this plugin.
4
4
  *
5
- * Design (industry-normal layering, kept dependency-free):
6
- * - `error` / `warn` are ALWAYS emitted (they are the anomaly guard: rare,
7
- * cheap, and must surface even for a user who never touched the switch).
8
- * - `info` / `debug` are gated by a DEBUG switch and further filtered by
9
- * namespace, so verbose detail never floods a normal user's console.
5
+ * `error` / `warn` are ALWAYS emitted (the anomaly guard: rare, cheap, and
6
+ * surfaced even for a user who never touched the switch). `info` / `debug` are
7
+ * gated by the exclusively-own, per-origin `localStorage['dsh-rewind.debug']`
8
+ * switch and further filtered by namespace. The switch is read on every call
9
+ * (never cached), so flipping it and reloading takes effect on any published
10
+ * build without a plugin rebuild.
10
11
  *
11
- * The DEBUG switch is a runtime, per-origin knob read from
12
- * `localStorage['dsh-rewind.debug']` — the convention-debug-scan pattern
13
- * (namespace match, comma-separated, `*` wildcard), scoped to an
14
- * exclusively-own key so it can never enable any other plugin/feature and no
15
- * other feature can wake this one. Because it is read on every call (never
16
- * cached), flipping it and reloading takes effect on any published build
17
- * without a plugin rebuild.
18
- *
19
- * Values accepted by the switch (empty/unset = off):
20
- * - `dsh-rewind*` — every dsh-rewind namespace.
21
- * - `dsh-rewind:refill` — just one subsystem (exact match).
22
- * - `dsh-rewind:refill,dsh-rewind:hiding` — several (comma-separated).
12
+ * Values accepted (empty/unset = off): `dsh-rewind*` — every namespace;
13
+ * `dsh-rewind:refill` — one subsystem (exact match);
14
+ * `dsh-rewind:refill,dsh-rewind:portals` — several (comma-separated).
23
15
  *
24
16
  * @module dsh-rewind/client/log
25
17
  */
@@ -3,12 +3,14 @@
3
3
  * bubble rows with the session's `next-step` inbox rows.
4
4
  *
5
5
  * Both sides follow the same host order, so index-primary matching is reliable;
6
- * text equality is still verified per row, and a row that fails (or has no
6
+ * the match key is still verified per row, and a row that fails (or has no
7
7
  * counterpart) is skipped INDIVIDUALLY: one bad row never takes down the other
8
- * rows' buttons. The compared text excludes the bubble's actions container —
9
- * the copy button's Tooltip mounts a label bubble there on hover, so the row's
10
- * full `textContent` would flip between "message" and "message+Copy" with the
11
- * mouse (see `bubbleTextOf` in portals.tsx).
8
+ * rows' buttons. The key is `(text, attachment count)`: exact text for a
9
+ * text-only message, the count for one that carries attachments — whose
10
+ * rendered attachment block contributes text no inbox row reproduces.
11
+ *
12
+ * The compared text excludes the bubble's actions container and its
13
+ * message-attachment block (see `bubbleTextOf` in portals.tsx).
12
14
  *
13
15
  * The browser half lives in `portals.tsx`; this module stays DOM-free so the
14
16
  * matching contract is unit-testable in a plain node environment.
@@ -19,6 +21,8 @@
19
21
  export interface PendingRow {
20
22
  /** The bubble's message text, excluding the actions container (see module doc). */
21
23
  readonly text: string;
24
+ /** Message-attachment blocks the row renders; compared against the item's count. */
25
+ readonly attachments: number;
22
26
  }
23
27
  /** One wire content block of an inbox row (only the fields the derivation reads). */
24
28
  export interface InboxBlockLike {
@@ -51,8 +55,10 @@ export interface InboxLike {
51
55
  /** One steering occurrence derived from the session's inbox projection. */
52
56
  export interface PendingSteeringItem {
53
57
  readonly id: string;
54
- /** Complete editable text; null when the message contains non-text blocks. */
58
+ /** Complete editable text; null when the message carries no text block at all. */
55
59
  readonly text: string | null;
60
+ /** Non-text blocks (image/file/…) the message carries, in prompt order. */
61
+ readonly attachments: number;
56
62
  /** Space-collapsed preview with image/file blocks excluded (the harness QueueDock rule). */
57
63
  readonly preview: string;
58
64
  }
@@ -72,11 +78,16 @@ export interface PendingSteeringItem {
72
78
  */
73
79
  export declare function steeringItemsOf(nextStep: readonly InboxMessageLike[] | undefined): readonly PendingSteeringItem[];
74
80
  /**
75
- * Pair rows to steering items by index, verifying text equality per row.
81
+ * Pair rows to steering items by index, verifying the match key per row: the
82
+ * visible text when the message has no attachments, otherwise the attachment
83
+ * count. Exact text equality cannot apply to an attachment-bearing row — which
84
+ * is why its ↶ button never mounted — because the rendered attachment block
85
+ * contributes text no inbox row reproduces.
86
+ *
76
87
  * @param rows - pending bubble rows in DOM order (== render order).
77
88
  * @param steering - steering items in host order (== render order).
78
89
  * @returns the item id for each row, or null for rows that cannot be matched
79
- * safely (missing counterpart, text mismatch). A bad row never affects the
90
+ * safely (missing counterpart, key mismatch). A bad row never affects the
80
91
  * other rows.
81
92
  */
82
93
  export declare function matchPendingRows(rows: readonly PendingRow[], steering: readonly PendingSteeringItem[]): readonly (string | null)[];