dsh-retrace 0.4.3 → 0.4.5

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.
@@ -63,6 +63,8 @@ var zh = {
63
63
  "marker.regenerate": "\u5DF2\u91CD\u65B0\u751F\u6210\u56DE\u590D",
64
64
  "marker.originalLabel": "\u539F\u8F93\u5165",
65
65
  "marker.referenceHint": "\u70B9\u51FB\u5C55\u5F00\u67E5\u770B\u539F\u63D0\u95EE\uFF08\u4EC5\u4F5C\u5BF9\u7167\uFF0C\u4E0D\u4F1A\u8FDB\u5165\u6A21\u578B\u4E0A\u4E0B\u6587\uFF09",
66
+ "marker.degradedHint": "\u6B64\u64CD\u4F5C\u6D89\u53CA\u5927\u8303\u56F4\u5BF9\u8BDD\uFF0C\u4E3A\u4FDD\u62A4\u5386\u53F2\u672A\u9690\u85CF\u5185\u5BB9\uFF08\u65E5\u5FD7\u5B8C\u597D\uFF09\u3002",
67
+ "marker.unionHint": "\u5DF2\u7D2F\u79EF\u9690\u85CF\u7EA6 {count}% \u7684\u5386\u53F2\u6D88\u606F\uFF1B\u53EF\u5728 \u8BBE\u7F6E\u2192\u901A\u7528 \u5173\u95ED\u300C\u6309\u6807\u8BB0\u9690\u85CF\u300D\u67E5\u770B\u5B8C\u6574\u5386\u53F2\u3002",
66
68
  "options.title": "\u6D88\u606F\u7F16\u8F91\u63D2\u4EF6",
67
69
  "options.showOriginalInput": "\u7F16\u8F91\u540E\u663E\u793A\u539F\u63D0\u95EE\u5BF9\u7167",
68
70
  "options.editFromScratch": "\u7F16\u8F91\u540E\u4ECE\u65B0\u5BF9\u8BDD\u5F00\u59CB\uFF08\u9690\u85CF\u6B64\u524D\u7684\u6D88\u606F\uFF0C\u9ED8\u8BA4\u5173\uFF09",
@@ -148,6 +150,8 @@ var en = {
148
150
  "marker.regenerate": "Reply regenerated",
149
151
  "marker.originalLabel": "Original input",
150
152
  "marker.referenceHint": "Click to expand the original input (reference only, never sent to the model)",
153
+ "marker.degradedHint": "This operation spans a large part of the conversation; content stays visible to protect your history (the log is intact).",
154
+ "marker.unionHint": 'About {count}% of the history is hidden in total; disable "Hide shadowed messages" in Settings \u2192 General to review the full history.',
151
155
  "options.title": "Message editor plugin",
152
156
  "options.showOriginalInput": "Show the original input after editing",
153
157
  "options.editFromScratch": "Start a fresh conversation after editing (hide earlier messages, default off)",
@@ -371,23 +375,32 @@ var recallMarkerDefinition = {
371
375
  kind: "recall-marker",
372
376
  target: "chat",
373
377
  match: (event) => {
374
- if (event.type !== "assistant/message" || !isReplacementSurfaceEvent(event)) return null;
375
- const id = event.data?.message?.id;
376
- if (!isMarkerId(id)) return null;
377
- return { id: `marker:${id}`, role: "start" };
378
+ if (!isReplacementSurfaceEvent(event)) return null;
379
+ if (event.type === "assistant/message") {
380
+ const id = event.data?.message?.id;
381
+ if (!isMarkerId(id)) return null;
382
+ return { id: `marker:${id}`, role: "start" };
383
+ }
384
+ if (event.type === "user/message" && isCompactCheckpoint(event.data?.source)) {
385
+ return { id: `marker:compact:${event.seq}`, role: "start" };
386
+ }
387
+ return null;
378
388
  },
379
389
  start: (_context, match) => {
380
390
  const event = match.event;
381
- const id = String(event.data.message.id);
382
- const legacy = isLegacyMarkerId(id);
391
+ const compact = event.type === "user/message" && isCompactCheckpoint(event.data?.source);
392
+ const id = compact ? "" : String(event.data.message.id);
393
+ const legacy = !compact && isLegacyMarkerId(id);
383
394
  return {
384
395
  seq: event.seq,
385
396
  time: event.time,
386
- op: markerOpFromId(id),
397
+ op: compact ? "compaction" : markerOpFromId(id),
387
398
  legacy,
388
- // Legacy markers never hide: treat their shadowed range as empty so the
389
- // notice/reference render but no row is hidden and no action row is
390
- // suppressed via useShadowed.
399
+ compact,
400
+ // Legacy/compact markers never hide: their shadowed range is kept for
401
+ // the action-row suppression check (useShadowed) but empty for legacy
402
+ // so no row is hidden and no action row is suppressed for legacy
403
+ // markers (rename must never make visible content disappear).
391
404
  shadowedSeqs: legacy ? [] : Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [],
392
405
  targetSeq: event.data?.editor?.targetSeq,
393
406
  text: event.data?.editor?.text
@@ -399,6 +412,9 @@ var recallMarkerDefinition = {
399
412
  return chatNodeLike(context, "recall-marker", context.state.seq, context.state);
400
413
  }
401
414
  };
415
+ function isCompactCheckpoint(source) {
416
+ return Boolean(source) && source.kind === "plugin" && source.plugin === "compact";
417
+ }
402
418
  function textOf(content) {
403
419
  if (!Array.isArray(content)) return "";
404
420
  return content.filter((block) => block && block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
@@ -413,17 +429,6 @@ function useMessageSeq(useSession, messageId) {
413
429
  return void 0;
414
430
  });
415
431
  }
416
- function useShadowed(useSession, seq) {
417
- return useSession((snapshot) => {
418
- if (seq === void 0 || seq === null) return false;
419
- for (const node of snapshot.chat.nodes.values()) {
420
- if (node.kind === "recall-marker" && Array.isArray(node.data?.shadowedSeqs) && node.data.shadowedSeqs.includes(seq)) {
421
- return true;
422
- }
423
- }
424
- return false;
425
- });
426
- }
427
432
  var SHADOW_SAFETY_RATIO = 0.4;
428
433
  function hiddenKeysFor(shadowedSeqs, nodes) {
429
434
  if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null;
@@ -441,35 +446,94 @@ function hiddenKeysFor(shadowedSeqs, nodes) {
441
446
  if (typeof resultSeq === "number" && hidden.has(resultSeq)) keys.push(node.key);
442
447
  continue;
443
448
  }
444
- if (typeof node.anchorSeq === "number" && hidden.has(node.anchorSeq)) keys.push(node.key);
449
+ if (typeof node.anchorSeq === "number") {
450
+ const anchored = node.anchorSeq % 1 === 0 ? node.anchorSeq : Math.ceil(node.anchorSeq);
451
+ if (hidden.has(anchored)) keys.push(node.key);
452
+ }
445
453
  }
446
454
  return keys.length === 0 ? null : keys;
447
455
  }
448
- var EMPTY_HIDE_PLAN = { degraded: false, hiddenFor: () => null };
456
+ var EMPTY_HIDE_PLAN = Object.freeze({
457
+ hiddenFor: () => null,
458
+ planFor: () => null,
459
+ unionRatio: 0,
460
+ firstMarkerKey: null
461
+ });
462
+ var PLUGIN_PSEUDO_KINDS = /* @__PURE__ */ new Set(["user-actions", "retrace-reference", "recall-marker"]);
463
+ function realRowCount(nodes) {
464
+ let count = 0;
465
+ for (const node of nodes.values()) {
466
+ if (typeof node.anchorSeq === "number" && !PLUGIN_PSEUDO_KINDS.has(node.kind)) count += 1;
467
+ }
468
+ return count;
469
+ }
470
+ var hidePlanCacheSnapshot = null;
471
+ var hidePlanCacheValue = null;
449
472
  function useMarkerHidePlan(useSession) {
450
473
  return useSession((snapshot) => {
474
+ if (hidePlanCacheSnapshot === snapshot) return hidePlanCacheValue;
451
475
  const nodes = snapshot.chat.nodes;
452
- let rowCount = 0;
476
+ const rowCount = realRowCount(nodes);
453
477
  const markers = [];
454
478
  for (const node of nodes.values()) {
455
- if (typeof node.anchorSeq === "number") rowCount += 1;
456
- if (node.kind === "recall-marker") {
457
- markers.push({ key: node.key, shadowedSeqs: node.data?.shadowedSeqs });
458
- }
479
+ if (node.kind === "recall-marker" && !node.data?.compact) markers.push(node);
459
480
  }
460
- if (markers.length === 0) return EMPTY_HIDE_PLAN;
481
+ if (markers.length === 0) {
482
+ hidePlanCacheSnapshot = snapshot;
483
+ hidePlanCacheValue = EMPTY_HIDE_PLAN;
484
+ return hidePlanCacheValue;
485
+ }
486
+ const plans = /* @__PURE__ */ new Map();
461
487
  const union = /* @__PURE__ */ new Set();
462
- const perMarker = /* @__PURE__ */ new Map();
463
488
  for (const marker of markers) {
464
- const keys = hiddenKeysFor(marker.shadowedSeqs, nodes);
465
- perMarker.set(marker.key, keys);
489
+ const keys = hiddenKeysFor(marker.data?.shadowedSeqs, nodes);
490
+ const degraded = keys !== null && rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO;
491
+ plans.set(marker.key, { keys: degraded ? null : keys, degraded });
466
492
  if (keys !== null) for (const key of keys) union.add(key);
467
493
  }
468
- const degraded = rowCount > 0 && union.size / rowCount > SHADOW_SAFETY_RATIO;
469
- return {
470
- degraded,
471
- hiddenFor: (key) => degraded ? null : perMarker.get(key) ?? null
494
+ hidePlanCacheSnapshot = snapshot;
495
+ hidePlanCacheValue = {
496
+ planFor: (key) => plans.get(key) ?? null,
497
+ hiddenFor: (key) => plans.get(key)?.keys ?? null,
498
+ unionRatio: rowCount > 0 ? union.size / rowCount : 0,
499
+ firstMarkerKey: markers[0].key
472
500
  };
501
+ return hidePlanCacheValue;
502
+ });
503
+ }
504
+ function rowHiddenByKey(snapshot, rowKey) {
505
+ if (rowKey === void 0 || rowKey === null) return false;
506
+ const nodes = snapshot.chat.nodes;
507
+ const rowCount = realRowCount(nodes);
508
+ for (const node of nodes.values()) {
509
+ if (node.kind !== "recall-marker" || node.data?.compact) continue;
510
+ const keys = hiddenKeysFor(node.data?.shadowedSeqs, nodes);
511
+ if (keys === null) continue;
512
+ if (rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO) continue;
513
+ if (keys.includes(rowKey)) return true;
514
+ }
515
+ return false;
516
+ }
517
+ function useSeqHidden(useSession, seq) {
518
+ return useSession((snapshot) => {
519
+ if (seq === void 0 || seq === null) return false;
520
+ for (const node of snapshot.chat.nodes.values()) {
521
+ if (node.kind !== "recall-marker" && typeof node.anchorSeq === "number" && node.anchorSeq === seq) {
522
+ return rowHiddenByKey(snapshot, node.key);
523
+ }
524
+ }
525
+ return false;
526
+ });
527
+ }
528
+ function useShadowed(useSession, seq) {
529
+ return useSession((snapshot) => {
530
+ if (seq === void 0 || seq === null) return false;
531
+ for (const node of snapshot.chat.nodes.values()) {
532
+ if (node.kind === "recall-marker" && Array.isArray(node.data?.shadowedSeqs) && node.data.shadowedSeqs.includes(seq)) {
533
+ return true;
534
+ }
535
+ }
536
+ return false;
473
537
  });
474
538
  }
475
539
  function useMarkerDismissed(useSession, markerSeq, op) {
@@ -506,10 +570,11 @@ function useEditReference(useSession, mySeq) {
506
570
  }
507
571
  function AssistantActions({ messageId, sessionId, useSession, t }) {
508
572
  const seq = useMessageSeq(useSession, messageId);
573
+ const hidden = useSeqHidden(useSession, seq);
509
574
  const shadowed = useShadowed(useSession, seq);
510
575
  const [busy, setBusy] = (0, import_react.useState)(false);
511
576
  const [failure, setFailure] = (0, import_react.useState)(null);
512
- if (shadowed || seq === void 0) return null;
577
+ if (hidden || shadowed || seq === void 0) return null;
513
578
  const run = (op) => {
514
579
  setBusy(true);
515
580
  setFailure(null);
@@ -552,11 +617,11 @@ function AssistantActions({ messageId, sessionId, useSession, t }) {
552
617
  }
553
618
  function ReferenceRow({ node, useSession, t }) {
554
619
  const { seq, messageId } = node.data;
555
- const shadowed = useShadowed(useSession, seq);
620
+ const hidden = useSeqHidden(useSession, seq);
556
621
  const markerRef = useEditReference(useSession, seq);
557
622
  const referenceText = editReferences.get(messageId) ?? markerRef;
558
623
  const config = useConfig();
559
- if (shadowed) return null;
624
+ if (hidden) return null;
560
625
  if (referenceText === null || !config.showOriginalInput) return null;
561
626
  return (0, import_react.createElement)("div", { className: "dsh-rt-user-row" }, [
562
627
  (0, import_react.createElement)("details", { className: "dsh-rt-reference" }, [
@@ -571,12 +636,13 @@ function ReferenceRow({ node, useSession, t }) {
571
636
  }
572
637
  function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
573
638
  const { seq, messageId, content } = node.data;
639
+ const hidden = useSeqHidden(useSession, seq);
574
640
  const shadowed = useShadowed(useSession, seq);
575
641
  const [editing, setEditing] = (0, import_react.useState)(false);
576
642
  const [draft, setDraft] = (0, import_react.useState)("");
577
643
  const [busy, setBusy] = (0, import_react.useState)(false);
578
644
  const [failure, setFailure] = (0, import_react.useState)(null);
579
- if (shadowed) return null;
645
+ if (hidden || shadowed) return null;
580
646
  const openEditor = () => {
581
647
  setDraft(textOf(content));
582
648
  setFailure(null);
@@ -669,16 +735,26 @@ function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
669
735
  ]);
670
736
  }
671
737
  function RecallMarkerRow({ node, useSession, t }) {
672
- const { seq, op, shadowedSeqs, legacy } = node.data;
738
+ const { seq, op, shadowedSeqs, legacy, compact } = node.data;
739
+ if (compact) return null;
673
740
  const dismissed = useMarkerDismissed(useSession, seq, op);
674
741
  const hidePlan = useMarkerHidePlan(useSession);
675
- const hiddenKeys = legacy || !getConfig().hideShadowed ? null : hidePlan.hiddenFor(node.key);
742
+ const plan = hidePlan.planFor(node.key);
743
+ const hiddenKeys = legacy || !getConfig().hideShadowed ? null : plan?.keys ?? null;
676
744
  const css = hiddenKeys === null ? null : hiddenKeys.map((key) => `[data-chat-anchor-key=${JSON.stringify(key)}]{display:none!important}`).join("");
677
745
  const count = Array.isArray(shadowedSeqs) ? shadowedSeqs.length : 0;
678
746
  const label = op === "recall" ? count > 1 ? t("marker.recallMany", { count }) : t("marker.recallOne") : op === "regenerate" ? t("marker.regenerate") : t("marker.edit");
747
+ const degradedHint = !legacy && plan?.degraded === true ? (0, import_react.createElement)("div", { key: "degraded", className: "dsh-rt-marker-hint" }, t("marker.degradedHint")) : null;
748
+ const unionHint = !legacy && hidePlan.firstMarkerKey === node.key && hidePlan.unionRatio > SHADOW_SAFETY_RATIO ? (0, import_react.createElement)(
749
+ "div",
750
+ { key: "union", className: "dsh-rt-marker-hint" },
751
+ t("marker.unionHint", { count: Math.round(hidePlan.unionRatio * 100) })
752
+ ) : null;
679
753
  return (0, import_react.createElement)("div", { className: "dsh-rt-marker-block", "data-dismissed": dismissed || void 0 }, [
680
754
  css !== null && (0, import_react.createElement)("style", { key: "hide", dangerouslySetInnerHTML: { __html: css } }),
681
- !dismissed && (0, import_react.createElement)("div", { key: "label", className: "dsh-rt-marker", role: "status" }, label)
755
+ !dismissed && (0, import_react.createElement)("div", { key: "label", className: "dsh-rt-marker", role: "status" }, label),
756
+ !dismissed && degradedHint,
757
+ !dismissed && unionHint
682
758
  ]);
683
759
  }
684
760
  function OptionsRow({ t }) {
@@ -755,6 +831,7 @@ var CSS = `
755
831
  .dsh-rt-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;max-width:min(525px,82%)}
756
832
  .dsh-rt-marker-block{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;max-width:var(--dsh-chat-content-width);box-sizing:border-box;margin:0 auto;padding:2px 0}
757
833
  .dsh-rt-marker{text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px}
834
+ .dsh-rt-marker-hint{text-align:center;color:var(--dsw-alias-state-warning-primary);font-size:11px;line-height:16px;margin-top:2px}
758
835
  .dsh-rt-reference{width:min(525px,82%);box-sizing:border-box;border:1px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:10px;padding:2px 12px}
759
836
  .dsh-rt-reference summary{color:var(--dsw-alias-label-caption);cursor:pointer;user-select:none;font-size:12px;line-height:22px;list-style:none;display:inline-flex;align-items:center;gap:6px;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
760
837
  .dsh-rt-reference summary::-webkit-details-marker{display:none}
@@ -836,7 +913,7 @@ function switchToViewTab(viewId) {
836
913
  console.warn(`[dsh-retrace] no tab order registered for "${viewId}"`);
837
914
  return;
838
915
  }
839
- const buttons = [...document.querySelectorAll('[role="tablist"] [role="tab"]')];
916
+ const buttons = [...document.querySelectorAll('[role="tablist"]')].filter((tablist) => tablist.getBoundingClientRect().width > 0).flatMap((tablist) => [...tablist.querySelectorAll('[role="tab"]')]);
840
917
  const button = buttons[index];
841
918
  if (!button) {
842
919
  console.warn(`[dsh-retrace] tab "${viewId}" (index ${index}) not found in the conversation tab bar`);
@@ -844,6 +921,11 @@ function switchToViewTab(viewId) {
844
921
  }
845
922
  button.click();
846
923
  }
924
+ function nodeCountOf(nodes) {
925
+ if (!nodes) return 0;
926
+ if (typeof nodes.size === "number") return nodes.size;
927
+ return [...nodes.values()].length;
928
+ }
847
929
  function waitForElement(selector, frames) {
848
930
  return new Promise((resolve) => {
849
931
  let remaining = frames;
@@ -879,9 +961,9 @@ async function jumpToAnchor(store, anchorSeq) {
879
961
  let key = keyOfSeq(anchorSeq);
880
962
  let pages = 0;
881
963
  while (key === null && pages < JUMP_PAGE_BUDGET && store.hasMore) {
882
- const before = store.getSnapshot()?.chat?.nodes?.size ?? 0;
964
+ const before = nodeCountOf(store.getSnapshot()?.chat?.nodes);
883
965
  await store.loadOlder();
884
- const after = store.getSnapshot()?.chat?.nodes?.size ?? 0;
966
+ const after = nodeCountOf(store.getSnapshot()?.chat?.nodes);
885
967
  pages += 1;
886
968
  if (after === before) break;
887
969
  key = keyOfSeq(anchorSeq);
@@ -1286,9 +1368,12 @@ function apply(ctx) {
1286
1368
  const t = ctx.locale.bind(NS);
1287
1369
  const conversationEvents = ctx.get("conversationEvents");
1288
1370
  if (conversationEvents) {
1289
- conversationEvents.register(userActionsDefinition);
1290
- conversationEvents.register(userReferenceDefinition);
1291
- conversationEvents.register(recallMarkerDefinition);
1371
+ const disposeDefinitions = [
1372
+ conversationEvents.register(userActionsDefinition),
1373
+ conversationEvents.register(userReferenceDefinition),
1374
+ conversationEvents.register(recallMarkerDefinition)
1375
+ ];
1376
+ ctx.effect(() => () => disposeDefinitions.forEach((dispose) => dispose()), "dsh-retrace: conversation definitions");
1292
1377
  }
1293
1378
  ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
1294
1379
  name: "conversation.chat.assistant-actions",
package/lib/client.js CHANGED
@@ -71,6 +71,8 @@ const zh = {
71
71
  'marker.regenerate': '已重新生成回复',
72
72
  'marker.originalLabel': '原输入',
73
73
  'marker.referenceHint': '点击展开查看原提问(仅作对照,不会进入模型上下文)',
74
+ 'marker.degradedHint': '此操作涉及大范围对话,为保护历史未隐藏内容(日志完好)。',
75
+ 'marker.unionHint': '已累积隐藏约 {count}% 的历史消息;可在 设置→通用 关闭「按标记隐藏」查看完整历史。',
74
76
  'options.title': '消息编辑插件',
75
77
  'options.showOriginalInput': '编辑后显示原提问对照',
76
78
  'options.editFromScratch': '编辑后从新对话开始(隐藏此前的消息,默认关)',
@@ -157,6 +159,8 @@ const en = {
157
159
  'marker.regenerate': 'Reply regenerated',
158
160
  'marker.originalLabel': 'Original input',
159
161
  'marker.referenceHint': 'Click to expand the original input (reference only, never sent to the model)',
162
+ 'marker.degradedHint': 'This operation spans a large part of the conversation; content stays visible to protect your history (the log is intact).',
163
+ 'marker.unionHint': 'About {count}% of the history is hidden in total; disable "Hide shadowed messages" in Settings → General to review the full history.',
160
164
  'options.title': 'Message editor plugin',
161
165
  'options.showOriginalInput': 'Show the original input after editing',
162
166
  'options.editFromScratch': 'Start a fresh conversation after editing (hide earlier messages, default off)',
@@ -453,23 +457,38 @@ const recallMarkerDefinition = {
453
457
  kind: 'recall-marker',
454
458
  target: 'chat',
455
459
  match: (event) => {
456
- if (event.type !== 'assistant/message' || !isReplacementSurfaceEvent(event)) return null
457
- const id = event.data?.message?.id
458
- if (!isMarkerId(id)) return null
459
- return { id: `marker:${id}`, role: 'start' }
460
+ if (!isReplacementSurfaceEvent(event)) return null
461
+ if (event.type === 'assistant/message') {
462
+ const id = event.data?.message?.id
463
+ if (!isMarkerId(id)) return null
464
+ return { id: `marker:${id}`, role: 'start' }
465
+ }
466
+ // Compaction checkpoints are user/message replaces with the official
467
+ // `plugin: compact` source. They are NOT our markers, but their shadow
468
+ // range tells us which messages were compacted away — used ONLY to hide
469
+ // the edit/recall entries for those messages (compacted rows are handled
470
+ // by the engine itself, so the checkpoint marker renders nothing and
471
+ // never injects hide rules).
472
+ if (event.type === 'user/message' && isCompactCheckpoint(event.data?.source)) {
473
+ return { id: `marker:compact:${event.seq}`, role: 'start' }
474
+ }
475
+ return null
460
476
  },
461
477
  start: (_context, match) => {
462
478
  const event = match.event
463
- const id = String(event.data.message.id)
464
- const legacy = isLegacyMarkerId(id)
479
+ const compact = event.type === 'user/message' && isCompactCheckpoint(event.data?.source)
480
+ const id = compact ? '' : String(event.data.message.id)
481
+ const legacy = !compact && isLegacyMarkerId(id)
465
482
  return {
466
483
  seq: event.seq,
467
484
  time: event.time,
468
- op: markerOpFromId(id),
485
+ op: compact ? 'compaction' : markerOpFromId(id),
469
486
  legacy,
470
- // Legacy markers never hide: treat their shadowed range as empty so the
471
- // notice/reference render but no row is hidden and no action row is
472
- // suppressed via useShadowed.
487
+ compact,
488
+ // Legacy/compact markers never hide: their shadowed range is kept for
489
+ // the action-row suppression check (useShadowed) but empty for legacy
490
+ // so no row is hidden and no action row is suppressed for legacy
491
+ // markers (rename must never make visible content disappear).
473
492
  shadowedSeqs: legacy ? [] : (Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : []),
474
493
  targetSeq: event.data?.editor?.targetSeq,
475
494
  text: event.data?.editor?.text,
@@ -482,6 +501,11 @@ const recallMarkerDefinition = {
482
501
  },
483
502
  }
484
503
 
504
+ /** Official compaction checkpoint source: `{kind:'plugin', plugin:'compact'}`. */
505
+ function isCompactCheckpoint(source) {
506
+ return Boolean(source) && source.kind === 'plugin' && source.plugin === 'compact'
507
+ }
508
+
485
509
  // ---------------------------------------------------------------------------
486
510
  // Shared selector helpers
487
511
  // ---------------------------------------------------------------------------
@@ -505,25 +529,18 @@ function useMessageSeq(useSession, messageId) {
505
529
  })
506
530
  }
507
531
 
508
- /** True when `seq` was shadowed by any recall/edit/regenerate marker. */
509
- function useShadowed(useSession, seq) {
510
- return useSession((snapshot) => {
511
- if (seq === undefined || seq === null) return false
512
- for (const node of snapshot.chat.nodes.values()) {
513
- if (node.kind === 'recall-marker' && Array.isArray(node.data?.shadowedSeqs)
514
- && node.data.shadowedSeqs.includes(seq)) {
515
- return true
516
- }
517
- }
518
- return false
519
- })
520
- }
521
-
522
532
  /**
523
- * If ALL markers together would hide more than this share of the conversation's
524
- * rows, hiding is refused for EVERY marker (they render as notices only). A
525
- * recall/edit even several stacked edits must never blank out most of the
526
- * history: the rows stay in the durable log and remain visible.
533
+ * Safety guard: a SINGLE marker that would hide more than this share of the
534
+ * conversation's rows is refused (the marker renders as a notice only, and an
535
+ * explicit hint explains why). Ordinary recalls/edits shadow a few rows and
536
+ * always hide — the guard only ever trips on whole-surface operations such as
537
+ * "edit, start a fresh conversation". History must never silently vanish.
538
+ *
539
+ * Note (0.4.3 regression, fixed): the previous UNION-wide guard degraded EVERY
540
+ * marker (including fresh recalls) once the session's markers collectively
541
+ * covered >40% of the rows — recall/edit silently stopped hiding. The guard is
542
+ * per-marker again; a stacked-edit session still hides each replaced round,
543
+ * and a visible hint reports how much history is hidden in total.
527
544
  */
528
545
  const SHADOW_SAFETY_RATIO = 0.4
529
546
 
@@ -553,43 +570,141 @@ function hiddenKeysFor(shadowedSeqs, nodes) {
553
570
  if (typeof resultSeq === 'number' && hidden.has(resultSeq)) keys.push(node.key)
554
571
  continue
555
572
  }
556
- if (typeof node.anchorSeq === 'number' && hidden.has(node.anchorSeq)) keys.push(node.key)
573
+ if (typeof node.anchorSeq === 'number') {
574
+ // Pseudo rows anchor at HALF seqs to order before their message
575
+ // (retrace-reference uses `seq - 0.5`); the shadow set holds whole
576
+ // seqs, so map the anchor back to its integer seq before matching —
577
+ // otherwise the original-input reference survives the message it
578
+ // belongs to (visible residue after an edit).
579
+ const anchored = node.anchorSeq % 1 === 0 ? node.anchorSeq : Math.ceil(node.anchorSeq)
580
+ if (hidden.has(anchored)) keys.push(node.key)
581
+ }
557
582
  }
558
583
  return keys.length === 0 ? null : keys
559
584
  }
560
585
 
561
586
  /**
562
- * Global hide plan (0.4.3): one snapshot pass computes EVERY marker's hidden
563
- * keys and applies the safety guard to their UNION. A single normal edit still
564
- * hides its replaced round (a few rows ≪ the threshold); a session where
565
- * stacked edits would collectively hide most of the history degrades EVERY
566
- * marker to notice-only, so history can never silently vanish from the view.
587
+ * Hide plan (0.4.3 → per-marker): one snapshot pass computes every marker's
588
+ * hidden keys and applies the safety guard to each marker INDEPENDENTLY. A
589
+ * single normal recall/edit hides its replaced round (a few rows ≪ the
590
+ * threshold); only a marker that would hide most of the history by itself
591
+ * (e.g. "start a fresh conversation") degrades to notice-only. The plan also
592
+ * reports the collective ratio so the UI can hint when stacked edits hide a
593
+ * large share of the conversation.
567
594
  */
568
- const EMPTY_HIDE_PLAN = { degraded: false, hiddenFor: () => null }
595
+ const EMPTY_HIDE_PLAN = Object.freeze({
596
+ hiddenFor: () => null,
597
+ planFor: () => null,
598
+ unionRatio: 0,
599
+ firstMarkerKey: null,
600
+ })
601
+
602
+ /**
603
+ * Plugin pseudo-node kinds never represent a real conversation row; they must
604
+ * not count toward the safety-ratio denominator (0.4.x review: counting them
605
+ * diluted the 40% guard to ~55-68% of real rows).
606
+ */
607
+ const PLUGIN_PSEUDO_KINDS = new Set(['user-actions', 'retrace-reference', 'recall-marker'])
608
+
609
+ /** Count only REAL conversation rows (excludes the plugin's pseudo nodes). */
610
+ function realRowCount(nodes) {
611
+ let count = 0
612
+ for (const node of nodes.values()) {
613
+ if (typeof node.anchorSeq === 'number' && !PLUGIN_PSEUDO_KINDS.has(node.kind)) count += 1
614
+ }
615
+ return count
616
+ }
617
+
618
+ // Module-level memo: the conversation snapshot reference is stable between
619
+ // events, so the hide plan (O(nodes) per pass) is computed once per snapshot
620
+ // and shared by every marker row — same object reference → no re-render storm
621
+ // (0.4.x review: each marker row recomputed the whole table every snapshot).
622
+ let hidePlanCacheSnapshot = null
623
+ let hidePlanCacheValue = null
569
624
  function useMarkerHidePlan(useSession) {
570
625
  return useSession((snapshot) => {
626
+ if (hidePlanCacheSnapshot === snapshot) return hidePlanCacheValue
571
627
  const nodes = snapshot.chat.nodes
572
- let rowCount = 0
628
+ const rowCount = realRowCount(nodes)
573
629
  const markers = []
574
630
  for (const node of nodes.values()) {
575
- if (typeof node.anchorSeq === 'number') rowCount += 1
576
- if (node.kind === 'recall-marker') {
577
- markers.push({ key: node.key, shadowedSeqs: node.data?.shadowedSeqs })
578
- }
631
+ if (node.kind === 'recall-marker' && !node.data?.compact) markers.push(node)
632
+ }
633
+ if (markers.length === 0) {
634
+ hidePlanCacheSnapshot = snapshot
635
+ hidePlanCacheValue = EMPTY_HIDE_PLAN
636
+ return hidePlanCacheValue
579
637
  }
580
- if (markers.length === 0) return EMPTY_HIDE_PLAN
638
+ const plans = new Map()
581
639
  const union = new Set()
582
- const perMarker = new Map()
583
640
  for (const marker of markers) {
584
- const keys = hiddenKeysFor(marker.shadowedSeqs, nodes)
585
- perMarker.set(marker.key, keys)
641
+ const keys = hiddenKeysFor(marker.data?.shadowedSeqs, nodes)
642
+ const degraded = keys !== null && rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO
643
+ plans.set(marker.key, { keys: degraded ? null : keys, degraded })
586
644
  if (keys !== null) for (const key of keys) union.add(key)
587
645
  }
588
- const degraded = rowCount > 0 && union.size / rowCount > SHADOW_SAFETY_RATIO
589
- return {
590
- degraded,
591
- hiddenFor: (key) => (degraded ? null : (perMarker.get(key) ?? null)),
646
+ hidePlanCacheSnapshot = snapshot
647
+ hidePlanCacheValue = {
648
+ planFor: (key) => plans.get(key) ?? null,
649
+ hiddenFor: (key) => plans.get(key)?.keys ?? null,
650
+ unionRatio: rowCount > 0 ? union.size / rowCount : 0,
651
+ firstMarkerKey: markers[0].key,
652
+ }
653
+ return hidePlanCacheValue
654
+ })
655
+ }
656
+
657
+ /**
658
+ * True when the chat row `key` is actually hidden right now — i.e. some
659
+ * marker that is NOT degraded includes it in its hide rules. This mirrors the
660
+ * CSS reality: a degraded marker hides nothing, so rows it shadowed stay
661
+ * visible AND stay operable (their action rows must not vanish).
662
+ */
663
+ function rowHiddenByKey(snapshot, rowKey) {
664
+ if (rowKey === undefined || rowKey === null) return false
665
+ const nodes = snapshot.chat.nodes
666
+ const rowCount = realRowCount(nodes)
667
+ for (const node of nodes.values()) {
668
+ if (node.kind !== 'recall-marker' || node.data?.compact) continue // compact markers never hide rows
669
+ const keys = hiddenKeysFor(node.data?.shadowedSeqs, nodes)
670
+ if (keys === null) continue
671
+ if (rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO) continue // degraded: hides nothing
672
+ if (keys.includes(rowKey)) return true
673
+ }
674
+ return false
675
+ }
676
+
677
+ /** Same as rowHiddenByKey, but resolves the row key from a surface seq. */
678
+ function useSeqHidden(useSession, seq) {
679
+ return useSession((snapshot) => {
680
+ if (seq === undefined || seq === null) return false
681
+ for (const node of snapshot.chat.nodes.values()) {
682
+ if (node.kind !== 'recall-marker' && typeof node.anchorSeq === 'number' && node.anchorSeq === seq) {
683
+ return rowHiddenByKey(snapshot, node.key)
684
+ }
685
+ }
686
+ return false
687
+ })
688
+ }
689
+
690
+ /**
691
+ * True when `seq` was shadowed by ANY recall/edit/regenerate/compaction
692
+ * marker — the OPERATION-FEASIBILITY dimension, distinct from visual hiding:
693
+ * a shadowed message can never be edited/recalled again (the host rejects it
694
+ * with target-shadowed), so its action entries must be hidden even when the
695
+ * row itself stays visible (guard-degraded or compacted).
696
+ */
697
+ function useShadowed(useSession, seq) {
698
+ return useSession((snapshot) => {
699
+ if (seq === undefined || seq === null) return false
700
+ for (const node of snapshot.chat.nodes.values()) {
701
+ // Include compact markers: compacted messages are also un-editable.
702
+ if (node.kind === 'recall-marker' && Array.isArray(node.data?.shadowedSeqs)
703
+ && node.data.shadowedSeqs.includes(seq)) {
704
+ return true
705
+ }
592
706
  }
707
+ return false
593
708
  })
594
709
  }
595
710
 
@@ -644,10 +759,14 @@ function useEditReference(useSession, mySeq) {
644
759
  /** 撤回 / 重新生成 strip inside a finalized assistant reply's IconActions row. */
645
760
  function AssistantActions({ messageId, sessionId, useSession, t }) {
646
761
  const seq = useMessageSeq(useSession, messageId)
762
+ const hidden = useSeqHidden(useSession, seq)
647
763
  const shadowed = useShadowed(useSession, seq)
648
764
  const [busy, setBusy] = useState(false)
649
765
  const [failure, setFailure] = useState(null)
650
- if (shadowed || seq === undefined) return null
766
+ // Hidden (visually) or shadowed (un-editable: recalled/edited/compacted
767
+ // away) messages must not offer recall/regenerate — the host would reject
768
+ // with target-shadowed.
769
+ if (hidden || shadowed || seq === undefined) return null
651
770
 
652
771
  const run = (op) => {
653
772
  setBusy(true)
@@ -694,11 +813,15 @@ function AssistantActions({ messageId, sessionId, useSession, t }) {
694
813
  /** 原输入 reference block, rendered just above the re-sent message. */
695
814
  function ReferenceRow({ node, useSession, t }) {
696
815
  const { seq, messageId } = node.data
697
- const shadowed = useShadowed(useSession, seq)
816
+ // The reference node anchors at seq-0.5 (above the message) and never
817
+ // appears in any hide rule — judge by the REAL message seq so a shadowed
818
+ // re-send's reference disappears with it (0.4.4 regression: judging by the
819
+ // node key left stale "original input" blocks after a second edit).
820
+ const hidden = useSeqHidden(useSession, seq)
698
821
  const markerRef = useEditReference(useSession, seq)
699
822
  const referenceText = editReferences.get(messageId) ?? markerRef
700
823
  const config = useConfig()
701
- if (shadowed) return null
824
+ if (hidden) return null
702
825
  if (referenceText === null || !config.showOriginalInput) return null
703
826
  return createElement('div', { className: 'dsh-rt-user-row' }, [
704
827
  createElement('details', { className: 'dsh-rt-reference' }, [
@@ -712,12 +835,18 @@ function ReferenceRow({ node, useSession, t }) {
712
835
  /** 编辑 / 撤回 action row under one user message; recall echoes into the composer. */
713
836
  function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
714
837
  const { seq, messageId, content } = node.data
838
+ // Two independent dimensions: visual hiding (guard-protected, row stays
839
+ // visible when degraded) vs operation feasibility (a shadowed message can
840
+ // never be edited again — the host rejects with target-shadowed). Hide the
841
+ // edit/recall entries when EITHER applies, so compacted or recalled rows
842
+ // that remain visible don't offer operations that would just fail.
843
+ const hidden = useSeqHidden(useSession, seq)
715
844
  const shadowed = useShadowed(useSession, seq)
716
845
  const [editing, setEditing] = useState(false)
717
846
  const [draft, setDraft] = useState('')
718
847
  const [busy, setBusy] = useState(false)
719
848
  const [failure, setFailure] = useState(null)
720
- if (shadowed) return null
849
+ if (hidden || shadowed) return null
721
850
 
722
851
  const openEditor = () => {
723
852
  setDraft(textOf(content))
@@ -818,10 +947,14 @@ function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
818
947
 
819
948
  /** The transient notice row: hides shadowed content, dismissed after the user keeps typing. */
820
949
  function RecallMarkerRow({ node, useSession, t }) {
821
- const { seq, op, shadowedSeqs, legacy } = node.data
950
+ const { seq, op, shadowedSeqs, legacy, compact } = node.data
951
+ // Compaction checkpoints render nothing here: their only role is feeding
952
+ // useShadowed so compacted messages lose their edit/recall entries.
953
+ if (compact) return null
822
954
  const dismissed = useMarkerDismissed(useSession, seq, op)
823
955
  const hidePlan = useMarkerHidePlan(useSession)
824
- const hiddenKeys = legacy || !getConfig().hideShadowed ? null : hidePlan.hiddenFor(node.key)
956
+ const plan = hidePlan.planFor(node.key)
957
+ const hiddenKeys = legacy || !getConfig().hideShadowed ? null : (plan?.keys ?? null)
825
958
 
826
959
  // The hide rules must stay mounted even after the notice is dismissed,
827
960
  // otherwise the recalled message would reappear. Legacy markers and the
@@ -833,10 +966,21 @@ function RecallMarkerRow({ node, useSession, t }) {
833
966
  const label = op === 'recall'
834
967
  ? (count > 1 ? t('marker.recallMany', { count }) : t('marker.recallOne'))
835
968
  : op === 'regenerate' ? t('marker.regenerate') : t('marker.edit')
969
+ // A per-marker safety-guard trip (e.g. "start a fresh conversation") and a
970
+ // collective-hide hint appear once, on the first marker row.
971
+ const degradedHint = !legacy && plan?.degraded === true
972
+ ? createElement('div', { key: 'degraded', className: 'dsh-rt-marker-hint' }, t('marker.degradedHint'))
973
+ : null
974
+ const unionHint = !legacy && hidePlan.firstMarkerKey === node.key && hidePlan.unionRatio > SHADOW_SAFETY_RATIO
975
+ ? createElement('div', { key: 'union', className: 'dsh-rt-marker-hint' },
976
+ t('marker.unionHint', { count: Math.round(hidePlan.unionRatio * 100) }))
977
+ : null
836
978
 
837
979
  return createElement('div', { className: 'dsh-rt-marker-block', 'data-dismissed': dismissed || undefined }, [
838
980
  css !== null && createElement('style', { key: 'hide', dangerouslySetInnerHTML: { __html: css } }),
839
981
  !dismissed && createElement('div', { key: 'label', className: 'dsh-rt-marker', role: 'status' }, label),
982
+ !dismissed && degradedHint,
983
+ !dismissed && unionHint,
840
984
  ])
841
985
  }
842
986
 
@@ -919,6 +1063,7 @@ const CSS = `
919
1063
  .dsh-rt-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;max-width:min(525px,82%)}
920
1064
  .dsh-rt-marker-block{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;max-width:var(--dsh-chat-content-width);box-sizing:border-box;margin:0 auto;padding:2px 0}
921
1065
  .dsh-rt-marker{text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px}
1066
+ .dsh-rt-marker-hint{text-align:center;color:var(--dsw-alias-state-warning-primary);font-size:11px;line-height:16px;margin-top:2px}
922
1067
  .dsh-rt-reference{width:min(525px,82%);box-sizing:border-box;border:1px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:10px;padding:2px 12px}
923
1068
  .dsh-rt-reference summary{color:var(--dsw-alias-label-caption);cursor:pointer;user-select:none;font-size:12px;line-height:22px;list-style:none;display:inline-flex;align-items:center;gap:6px;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
924
1069
  .dsh-rt-reference summary::-webkit-details-marker{display:none}
@@ -1015,7 +1160,12 @@ function switchToViewTab(viewId) {
1015
1160
  console.warn(`[dsh-retrace] no tab order registered for "${viewId}"`)
1016
1161
  return
1017
1162
  }
1018
- const buttons = [...document.querySelectorAll('[role="tablist"] [role="tab"]')]
1163
+ // Only VISIBLE tablists count — hidden tabbars (settings etc.) must not
1164
+ // shift the index (0.4.x review: a document-wide query could click the
1165
+ // wrong control). The conversation view's tab bar is the visible one.
1166
+ const buttons = [...document.querySelectorAll('[role="tablist"]')]
1167
+ .filter((tablist) => tablist.getBoundingClientRect().width > 0)
1168
+ .flatMap((tablist) => [...tablist.querySelectorAll('[role="tab"]')])
1019
1169
  const button = buttons[index]
1020
1170
  if (!button) {
1021
1171
  console.warn(`[dsh-retrace] tab "${viewId}" (index ${index}) not found in the conversation tab bar`)
@@ -1024,6 +1174,13 @@ function switchToViewTab(viewId) {
1024
1174
  button.click()
1025
1175
  }
1026
1176
 
1177
+ /** Count chat nodes regardless of store shape (Map or iterator-only). */
1178
+ function nodeCountOf(nodes) {
1179
+ if (!nodes) return 0
1180
+ if (typeof nodes.size === 'number') return nodes.size
1181
+ return [...nodes.values()].length
1182
+ }
1183
+
1027
1184
  /** rAF poll for an element that appears after a view switch + render pass. */
1028
1185
  function waitForElement(selector, frames) {
1029
1186
  return new Promise((resolve) => {
@@ -1071,9 +1228,13 @@ async function jumpToAnchor(store, anchorSeq) {
1071
1228
  let key = keyOfSeq(anchorSeq)
1072
1229
  let pages = 0
1073
1230
  while (key === null && pages < JUMP_PAGE_BUDGET && store.hasMore) {
1074
- const before = store.getSnapshot()?.chat?.nodes?.size ?? 0
1231
+ // Map/iterator-agnostic node count: `.size` may be absent on the
1232
+ // snapshot's node store and `values()` yields an iterator with no
1233
+ // `.length` — spread it (0.4.x review: the loop used to break after one
1234
+ // page because the count was always undefined).
1235
+ const before = nodeCountOf(store.getSnapshot()?.chat?.nodes)
1075
1236
  await store.loadOlder()
1076
- const after = store.getSnapshot()?.chat?.nodes?.size ?? 0
1237
+ const after = nodeCountOf(store.getSnapshot()?.chat?.nodes)
1077
1238
  pages += 1
1078
1239
  if (after === before) break // page returned nothing
1079
1240
  key = keyOfSeq(anchorSeq)
@@ -1604,9 +1765,17 @@ export function apply(ctx) {
1604
1765
 
1605
1766
  const conversationEvents = ctx.get('conversationEvents')
1606
1767
  if (conversationEvents) {
1607
- conversationEvents.register(userActionsDefinition)
1608
- conversationEvents.register(userReferenceDefinition)
1609
- conversationEvents.register(recallMarkerDefinition)
1768
+ // register() returns a disposer that removes the definition from the
1769
+ // registry service's context. It MUST be wired into ctx.effect — otherwise
1770
+ // the definition survives plugin unload/hot-reload and the next apply
1771
+ // throws "already registered" (0.4.x review: the plugin would crash after
1772
+ // one toggle in Settings).
1773
+ const disposeDefinitions = [
1774
+ conversationEvents.register(userActionsDefinition),
1775
+ conversationEvents.register(userReferenceDefinition),
1776
+ conversationEvents.register(recallMarkerDefinition),
1777
+ ]
1778
+ ctx.effect(() => () => disposeDefinitions.forEach((dispose) => dispose()), 'dsh-retrace: conversation definitions')
1610
1779
  }
1611
1780
 
1612
1781
  ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
@@ -69,6 +69,8 @@ return {
69
69
  "marker.regenerate": "\u5DF2\u91CD\u65B0\u751F\u6210\u56DE\u590D",
70
70
  "marker.originalLabel": "\u539F\u8F93\u5165",
71
71
  "marker.referenceHint": "\u70B9\u51FB\u5C55\u5F00\u67E5\u770B\u539F\u63D0\u95EE\uFF08\u4EC5\u4F5C\u5BF9\u7167\uFF0C\u4E0D\u4F1A\u8FDB\u5165\u6A21\u578B\u4E0A\u4E0B\u6587\uFF09",
72
+ "marker.degradedHint": "\u6B64\u64CD\u4F5C\u6D89\u53CA\u5927\u8303\u56F4\u5BF9\u8BDD\uFF0C\u4E3A\u4FDD\u62A4\u5386\u53F2\u672A\u9690\u85CF\u5185\u5BB9\uFF08\u65E5\u5FD7\u5B8C\u597D\uFF09\u3002",
73
+ "marker.unionHint": "\u5DF2\u7D2F\u79EF\u9690\u85CF\u7EA6 {count}% \u7684\u5386\u53F2\u6D88\u606F\uFF1B\u53EF\u5728 \u8BBE\u7F6E\u2192\u901A\u7528 \u5173\u95ED\u300C\u6309\u6807\u8BB0\u9690\u85CF\u300D\u67E5\u770B\u5B8C\u6574\u5386\u53F2\u3002",
72
74
  "options.title": "\u6D88\u606F\u7F16\u8F91\u63D2\u4EF6",
73
75
  "options.showOriginalInput": "\u7F16\u8F91\u540E\u663E\u793A\u539F\u63D0\u95EE\u5BF9\u7167",
74
76
  "options.editFromScratch": "\u7F16\u8F91\u540E\u4ECE\u65B0\u5BF9\u8BDD\u5F00\u59CB\uFF08\u9690\u85CF\u6B64\u524D\u7684\u6D88\u606F\uFF0C\u9ED8\u8BA4\u5173\uFF09",
@@ -154,6 +156,8 @@ return {
154
156
  "marker.regenerate": "Reply regenerated",
155
157
  "marker.originalLabel": "Original input",
156
158
  "marker.referenceHint": "Click to expand the original input (reference only, never sent to the model)",
159
+ "marker.degradedHint": "This operation spans a large part of the conversation; content stays visible to protect your history (the log is intact).",
160
+ "marker.unionHint": 'About {count}% of the history is hidden in total; disable "Hide shadowed messages" in Settings \u2192 General to review the full history.',
157
161
  "options.title": "Message editor plugin",
158
162
  "options.showOriginalInput": "Show the original input after editing",
159
163
  "options.editFromScratch": "Start a fresh conversation after editing (hide earlier messages, default off)",
@@ -377,23 +381,32 @@ return {
377
381
  kind: "recall-marker",
378
382
  target: "chat",
379
383
  match: (event) => {
380
- if (event.type !== "assistant/message" || !isReplacementSurfaceEvent(event)) return null;
381
- const id = event.data?.message?.id;
382
- if (!isMarkerId(id)) return null;
383
- return { id: `marker:${id}`, role: "start" };
384
+ if (!isReplacementSurfaceEvent(event)) return null;
385
+ if (event.type === "assistant/message") {
386
+ const id = event.data?.message?.id;
387
+ if (!isMarkerId(id)) return null;
388
+ return { id: `marker:${id}`, role: "start" };
389
+ }
390
+ if (event.type === "user/message" && isCompactCheckpoint(event.data?.source)) {
391
+ return { id: `marker:compact:${event.seq}`, role: "start" };
392
+ }
393
+ return null;
384
394
  },
385
395
  start: (_context, match) => {
386
396
  const event = match.event;
387
- const id = String(event.data.message.id);
388
- const legacy = isLegacyMarkerId(id);
397
+ const compact = event.type === "user/message" && isCompactCheckpoint(event.data?.source);
398
+ const id = compact ? "" : String(event.data.message.id);
399
+ const legacy = !compact && isLegacyMarkerId(id);
389
400
  return {
390
401
  seq: event.seq,
391
402
  time: event.time,
392
- op: markerOpFromId(id),
403
+ op: compact ? "compaction" : markerOpFromId(id),
393
404
  legacy,
394
- // Legacy markers never hide: treat their shadowed range as empty so the
395
- // notice/reference render but no row is hidden and no action row is
396
- // suppressed via useShadowed.
405
+ compact,
406
+ // Legacy/compact markers never hide: their shadowed range is kept for
407
+ // the action-row suppression check (useShadowed) but empty for legacy
408
+ // so no row is hidden and no action row is suppressed for legacy
409
+ // markers (rename must never make visible content disappear).
397
410
  shadowedSeqs: legacy ? [] : Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [],
398
411
  targetSeq: event.data?.editor?.targetSeq,
399
412
  text: event.data?.editor?.text
@@ -405,6 +418,9 @@ return {
405
418
  return chatNodeLike(context, "recall-marker", context.state.seq, context.state);
406
419
  }
407
420
  };
421
+ function isCompactCheckpoint(source) {
422
+ return Boolean(source) && source.kind === "plugin" && source.plugin === "compact";
423
+ }
408
424
  function textOf(content) {
409
425
  if (!Array.isArray(content)) return "";
410
426
  return content.filter((block) => block && block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
@@ -419,17 +435,6 @@ return {
419
435
  return void 0;
420
436
  });
421
437
  }
422
- function useShadowed(useSession, seq) {
423
- return useSession((snapshot) => {
424
- if (seq === void 0 || seq === null) return false;
425
- for (const node of snapshot.chat.nodes.values()) {
426
- if (node.kind === "recall-marker" && Array.isArray(node.data?.shadowedSeqs) && node.data.shadowedSeqs.includes(seq)) {
427
- return true;
428
- }
429
- }
430
- return false;
431
- });
432
- }
433
438
  var SHADOW_SAFETY_RATIO = 0.4;
434
439
  function hiddenKeysFor(shadowedSeqs, nodes) {
435
440
  if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null;
@@ -447,35 +452,94 @@ return {
447
452
  if (typeof resultSeq === "number" && hidden.has(resultSeq)) keys.push(node.key);
448
453
  continue;
449
454
  }
450
- if (typeof node.anchorSeq === "number" && hidden.has(node.anchorSeq)) keys.push(node.key);
455
+ if (typeof node.anchorSeq === "number") {
456
+ const anchored = node.anchorSeq % 1 === 0 ? node.anchorSeq : Math.ceil(node.anchorSeq);
457
+ if (hidden.has(anchored)) keys.push(node.key);
458
+ }
451
459
  }
452
460
  return keys.length === 0 ? null : keys;
453
461
  }
454
- var EMPTY_HIDE_PLAN = { degraded: false, hiddenFor: () => null };
462
+ var EMPTY_HIDE_PLAN = Object.freeze({
463
+ hiddenFor: () => null,
464
+ planFor: () => null,
465
+ unionRatio: 0,
466
+ firstMarkerKey: null
467
+ });
468
+ var PLUGIN_PSEUDO_KINDS = /* @__PURE__ */ new Set(["user-actions", "retrace-reference", "recall-marker"]);
469
+ function realRowCount(nodes) {
470
+ let count = 0;
471
+ for (const node of nodes.values()) {
472
+ if (typeof node.anchorSeq === "number" && !PLUGIN_PSEUDO_KINDS.has(node.kind)) count += 1;
473
+ }
474
+ return count;
475
+ }
476
+ var hidePlanCacheSnapshot = null;
477
+ var hidePlanCacheValue = null;
455
478
  function useMarkerHidePlan(useSession) {
456
479
  return useSession((snapshot) => {
480
+ if (hidePlanCacheSnapshot === snapshot) return hidePlanCacheValue;
457
481
  const nodes = snapshot.chat.nodes;
458
- let rowCount = 0;
482
+ const rowCount = realRowCount(nodes);
459
483
  const markers = [];
460
484
  for (const node of nodes.values()) {
461
- if (typeof node.anchorSeq === "number") rowCount += 1;
462
- if (node.kind === "recall-marker") {
463
- markers.push({ key: node.key, shadowedSeqs: node.data?.shadowedSeqs });
464
- }
485
+ if (node.kind === "recall-marker" && !node.data?.compact) markers.push(node);
465
486
  }
466
- if (markers.length === 0) return EMPTY_HIDE_PLAN;
487
+ if (markers.length === 0) {
488
+ hidePlanCacheSnapshot = snapshot;
489
+ hidePlanCacheValue = EMPTY_HIDE_PLAN;
490
+ return hidePlanCacheValue;
491
+ }
492
+ const plans = /* @__PURE__ */ new Map();
467
493
  const union = /* @__PURE__ */ new Set();
468
- const perMarker = /* @__PURE__ */ new Map();
469
494
  for (const marker of markers) {
470
- const keys = hiddenKeysFor(marker.shadowedSeqs, nodes);
471
- perMarker.set(marker.key, keys);
495
+ const keys = hiddenKeysFor(marker.data?.shadowedSeqs, nodes);
496
+ const degraded = keys !== null && rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO;
497
+ plans.set(marker.key, { keys: degraded ? null : keys, degraded });
472
498
  if (keys !== null) for (const key of keys) union.add(key);
473
499
  }
474
- const degraded = rowCount > 0 && union.size / rowCount > SHADOW_SAFETY_RATIO;
475
- return {
476
- degraded,
477
- hiddenFor: (key) => degraded ? null : perMarker.get(key) ?? null
500
+ hidePlanCacheSnapshot = snapshot;
501
+ hidePlanCacheValue = {
502
+ planFor: (key) => plans.get(key) ?? null,
503
+ hiddenFor: (key) => plans.get(key)?.keys ?? null,
504
+ unionRatio: rowCount > 0 ? union.size / rowCount : 0,
505
+ firstMarkerKey: markers[0].key
478
506
  };
507
+ return hidePlanCacheValue;
508
+ });
509
+ }
510
+ function rowHiddenByKey(snapshot, rowKey) {
511
+ if (rowKey === void 0 || rowKey === null) return false;
512
+ const nodes = snapshot.chat.nodes;
513
+ const rowCount = realRowCount(nodes);
514
+ for (const node of nodes.values()) {
515
+ if (node.kind !== "recall-marker" || node.data?.compact) continue;
516
+ const keys = hiddenKeysFor(node.data?.shadowedSeqs, nodes);
517
+ if (keys === null) continue;
518
+ if (rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO) continue;
519
+ if (keys.includes(rowKey)) return true;
520
+ }
521
+ return false;
522
+ }
523
+ function useSeqHidden(useSession, seq) {
524
+ return useSession((snapshot) => {
525
+ if (seq === void 0 || seq === null) return false;
526
+ for (const node of snapshot.chat.nodes.values()) {
527
+ if (node.kind !== "recall-marker" && typeof node.anchorSeq === "number" && node.anchorSeq === seq) {
528
+ return rowHiddenByKey(snapshot, node.key);
529
+ }
530
+ }
531
+ return false;
532
+ });
533
+ }
534
+ function useShadowed(useSession, seq) {
535
+ return useSession((snapshot) => {
536
+ if (seq === void 0 || seq === null) return false;
537
+ for (const node of snapshot.chat.nodes.values()) {
538
+ if (node.kind === "recall-marker" && Array.isArray(node.data?.shadowedSeqs) && node.data.shadowedSeqs.includes(seq)) {
539
+ return true;
540
+ }
541
+ }
542
+ return false;
479
543
  });
480
544
  }
481
545
  function useMarkerDismissed(useSession, markerSeq, op) {
@@ -512,10 +576,11 @@ return {
512
576
  }
513
577
  function AssistantActions({ messageId, sessionId, useSession, t }) {
514
578
  const seq = useMessageSeq(useSession, messageId);
579
+ const hidden = useSeqHidden(useSession, seq);
515
580
  const shadowed = useShadowed(useSession, seq);
516
581
  const [busy, setBusy] = (0, import_react.useState)(false);
517
582
  const [failure, setFailure] = (0, import_react.useState)(null);
518
- if (shadowed || seq === void 0) return null;
583
+ if (hidden || shadowed || seq === void 0) return null;
519
584
  const run = (op) => {
520
585
  setBusy(true);
521
586
  setFailure(null);
@@ -558,11 +623,11 @@ return {
558
623
  }
559
624
  function ReferenceRow({ node, useSession, t }) {
560
625
  const { seq, messageId } = node.data;
561
- const shadowed = useShadowed(useSession, seq);
626
+ const hidden = useSeqHidden(useSession, seq);
562
627
  const markerRef = useEditReference(useSession, seq);
563
628
  const referenceText = editReferences.get(messageId) ?? markerRef;
564
629
  const config = useConfig();
565
- if (shadowed) return null;
630
+ if (hidden) return null;
566
631
  if (referenceText === null || !config.showOriginalInput) return null;
567
632
  return (0, import_react.createElement)("div", { className: "dsh-rt-user-row" }, [
568
633
  (0, import_react.createElement)("details", { className: "dsh-rt-reference" }, [
@@ -577,12 +642,13 @@ return {
577
642
  }
578
643
  function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
579
644
  const { seq, messageId, content } = node.data;
645
+ const hidden = useSeqHidden(useSession, seq);
580
646
  const shadowed = useShadowed(useSession, seq);
581
647
  const [editing, setEditing] = (0, import_react.useState)(false);
582
648
  const [draft, setDraft] = (0, import_react.useState)("");
583
649
  const [busy, setBusy] = (0, import_react.useState)(false);
584
650
  const [failure, setFailure] = (0, import_react.useState)(null);
585
- if (shadowed) return null;
651
+ if (hidden || shadowed) return null;
586
652
  const openEditor = () => {
587
653
  setDraft(textOf(content));
588
654
  setFailure(null);
@@ -675,16 +741,26 @@ return {
675
741
  ]);
676
742
  }
677
743
  function RecallMarkerRow({ node, useSession, t }) {
678
- const { seq, op, shadowedSeqs, legacy } = node.data;
744
+ const { seq, op, shadowedSeqs, legacy, compact } = node.data;
745
+ if (compact) return null;
679
746
  const dismissed = useMarkerDismissed(useSession, seq, op);
680
747
  const hidePlan = useMarkerHidePlan(useSession);
681
- const hiddenKeys = legacy || !getConfig().hideShadowed ? null : hidePlan.hiddenFor(node.key);
748
+ const plan = hidePlan.planFor(node.key);
749
+ const hiddenKeys = legacy || !getConfig().hideShadowed ? null : plan?.keys ?? null;
682
750
  const css = hiddenKeys === null ? null : hiddenKeys.map((key) => `[data-chat-anchor-key=${JSON.stringify(key)}]{display:none!important}`).join("");
683
751
  const count = Array.isArray(shadowedSeqs) ? shadowedSeqs.length : 0;
684
752
  const label = op === "recall" ? count > 1 ? t("marker.recallMany", { count }) : t("marker.recallOne") : op === "regenerate" ? t("marker.regenerate") : t("marker.edit");
753
+ const degradedHint = !legacy && plan?.degraded === true ? (0, import_react.createElement)("div", { key: "degraded", className: "dsh-rt-marker-hint" }, t("marker.degradedHint")) : null;
754
+ const unionHint = !legacy && hidePlan.firstMarkerKey === node.key && hidePlan.unionRatio > SHADOW_SAFETY_RATIO ? (0, import_react.createElement)(
755
+ "div",
756
+ { key: "union", className: "dsh-rt-marker-hint" },
757
+ t("marker.unionHint", { count: Math.round(hidePlan.unionRatio * 100) })
758
+ ) : null;
685
759
  return (0, import_react.createElement)("div", { className: "dsh-rt-marker-block", "data-dismissed": dismissed || void 0 }, [
686
760
  css !== null && (0, import_react.createElement)("style", { key: "hide", dangerouslySetInnerHTML: { __html: css } }),
687
- !dismissed && (0, import_react.createElement)("div", { key: "label", className: "dsh-rt-marker", role: "status" }, label)
761
+ !dismissed && (0, import_react.createElement)("div", { key: "label", className: "dsh-rt-marker", role: "status" }, label),
762
+ !dismissed && degradedHint,
763
+ !dismissed && unionHint
688
764
  ]);
689
765
  }
690
766
  function OptionsRow({ t }) {
@@ -761,6 +837,7 @@ return {
761
837
  .dsh-rt-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;max-width:min(525px,82%)}
762
838
  .dsh-rt-marker-block{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;max-width:var(--dsh-chat-content-width);box-sizing:border-box;margin:0 auto;padding:2px 0}
763
839
  .dsh-rt-marker{text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px}
840
+ .dsh-rt-marker-hint{text-align:center;color:var(--dsw-alias-state-warning-primary);font-size:11px;line-height:16px;margin-top:2px}
764
841
  .dsh-rt-reference{width:min(525px,82%);box-sizing:border-box;border:1px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:10px;padding:2px 12px}
765
842
  .dsh-rt-reference summary{color:var(--dsw-alias-label-caption);cursor:pointer;user-select:none;font-size:12px;line-height:22px;list-style:none;display:inline-flex;align-items:center;gap:6px;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
766
843
  .dsh-rt-reference summary::-webkit-details-marker{display:none}
@@ -842,7 +919,7 @@ return {
842
919
  console.warn(`[dsh-retrace] no tab order registered for "${viewId}"`);
843
920
  return;
844
921
  }
845
- const buttons = [...document.querySelectorAll('[role="tablist"] [role="tab"]')];
922
+ const buttons = [...document.querySelectorAll('[role="tablist"]')].filter((tablist) => tablist.getBoundingClientRect().width > 0).flatMap((tablist) => [...tablist.querySelectorAll('[role="tab"]')]);
846
923
  const button = buttons[index];
847
924
  if (!button) {
848
925
  console.warn(`[dsh-retrace] tab "${viewId}" (index ${index}) not found in the conversation tab bar`);
@@ -850,6 +927,11 @@ return {
850
927
  }
851
928
  button.click();
852
929
  }
930
+ function nodeCountOf(nodes) {
931
+ if (!nodes) return 0;
932
+ if (typeof nodes.size === "number") return nodes.size;
933
+ return [...nodes.values()].length;
934
+ }
853
935
  function waitForElement(selector, frames) {
854
936
  return new Promise((resolve) => {
855
937
  let remaining = frames;
@@ -885,9 +967,9 @@ return {
885
967
  let key = keyOfSeq(anchorSeq);
886
968
  let pages = 0;
887
969
  while (key === null && pages < JUMP_PAGE_BUDGET && store.hasMore) {
888
- const before = store.getSnapshot()?.chat?.nodes?.size ?? 0;
970
+ const before = nodeCountOf(store.getSnapshot()?.chat?.nodes);
889
971
  await store.loadOlder();
890
- const after = store.getSnapshot()?.chat?.nodes?.size ?? 0;
972
+ const after = nodeCountOf(store.getSnapshot()?.chat?.nodes);
891
973
  pages += 1;
892
974
  if (after === before) break;
893
975
  key = keyOfSeq(anchorSeq);
@@ -1292,9 +1374,12 @@ return {
1292
1374
  const t = ctx.locale.bind(NS);
1293
1375
  const conversationEvents = ctx.get("conversationEvents");
1294
1376
  if (conversationEvents) {
1295
- conversationEvents.register(userActionsDefinition);
1296
- conversationEvents.register(userReferenceDefinition);
1297
- conversationEvents.register(recallMarkerDefinition);
1377
+ const disposeDefinitions = [
1378
+ conversationEvents.register(userActionsDefinition),
1379
+ conversationEvents.register(userReferenceDefinition),
1380
+ conversationEvents.register(recallMarkerDefinition)
1381
+ ];
1382
+ ctx.effect(() => () => disposeDefinitions.forEach((dispose) => dispose()), "dsh-retrace: conversation definitions");
1298
1383
  }
1299
1384
  ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
1300
1385
  name: "conversation.chat.assistant-actions",
package/lib/versioning.js CHANGED
@@ -246,7 +246,11 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
246
246
  const global = opened.global.get()
247
247
  if (global) {
248
248
  // Seed session configs with the durable defaults (per-request overrides win).
249
- for (const [id, config] of configs) setConfig(id, { ...config, ...global })
249
+ // The domain schema spells the git switch `gitEnabled` (0.4.x review:
250
+ // it was merged in but never read — consumers use `config.git`).
251
+ const seeded = { ...global, git: global.gitEnabled ?? configs.get('')?.git ?? true }
252
+ delete seeded.gitEnabled
253
+ for (const [id, config] of configs) setConfig(id, { ...config, ...seeded })
250
254
  }
251
255
  })
252
256
  .catch((error) => log(`retrace: versioning disabled: ${String(error)}`))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-retrace",
3
3
  "description": "Retrace · 回溯 — Recall, edit-and-resend, regenerate, and conversation/artifact versioning (timeline, rollback, fork map) for DeepSeek Harness — Web and Desktop",
4
- "version": "0.4.3",
4
+ "version": "0.4.5",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",