dsh-retrace 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -126,7 +126,17 @@ return {
126
126
  "timeline.gitInitDesc": "\u5728\u5DE5\u4F5C\u533A\u6267\u884C git init\uFF08\u4EC5\u6DFB\u52A0 .gitignore \u4E0E\u4E00\u4E2A\u57FA\u7EBF\u63D0\u4EA4\uFF09\uFF0C\u7248\u672C\u56DE\u9000\u5C06\u4F18\u5148\u4F7F\u7528 git\u3002",
127
127
  "timeline.gitInitConfirm": "\u786E\u5B9A\u8981\u521D\u59CB\u5316 git \u5417\uFF1F",
128
128
  "error.generic": "\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
129
- "error.busy": "\u8BF7\u5148\u505C\u6B62\u5F53\u524D\u56DE\u590D\u518D\u64CD\u4F5C"
129
+ "error.busy": "\u8BF7\u5148\u505C\u6B62\u5F53\u524D\u56DE\u590D\u518D\u64CD\u4F5C",
130
+ "view.fork": "\u5206\u53C9",
131
+ "fork.title": "\u5206\u53C9\u56FE",
132
+ "fork.empty": "\u8FD8\u6CA1\u6709\u5206\u53C9\u3002\u64A4\u56DE / \u7F16\u8F91 / \u91CD\u65B0\u751F\u6210 / \u6062\u590D\u4F1A\u5728\u6B64\u4EA7\u751F\u5206\u53C9\u3002",
133
+ "fork.refresh": "\u5237\u65B0",
134
+ "fork.spine": "\u5F53\u524D\u8DEF\u5F84",
135
+ "fork.shadowed": "\u88AB\u906E\u853D {count} \u4E2A\u8282\u70B9",
136
+ "fork.node.user": "\u7528\u6237\u6D88\u606F",
137
+ "fork.node.assistant": "\u52A9\u624B\u56DE\u590D",
138
+ "fork.node.tool": "\u5DE5\u5177\u7ED3\u679C",
139
+ "fork.histTitle": "\u5386\u53F2\u5206\u53C9\u70B9"
130
140
  };
131
141
  var en = {
132
142
  "action.edit": "Edit",
@@ -201,7 +211,17 @@ return {
201
211
  "timeline.gitInitDesc": "Runs git init in the workspace (adds a minimal .gitignore and a baseline commit); rollback will prefer git.",
202
212
  "timeline.gitInitConfirm": "Initialize git in this workspace?",
203
213
  "error.generic": "Operation failed; please try again",
204
- "error.busy": "Stop the current reply before recalling or editing"
214
+ "error.busy": "Stop the current reply before recalling or editing",
215
+ "view.fork": "Fork",
216
+ "fork.title": "Fork map",
217
+ "fork.empty": "No forks yet. Recall / edit / regenerate / restore fork the path here.",
218
+ "fork.refresh": "Refresh",
219
+ "fork.spine": "Current path",
220
+ "fork.shadowed": "{count} shadowed nodes",
221
+ "fork.node.user": "User message",
222
+ "fork.node.assistant": "Assistant reply",
223
+ "fork.node.tool": "Tool result",
224
+ "fork.histTitle": "Historical fork points"
205
225
  };
206
226
  var SURFACE_TYPES = /* @__PURE__ */ new Set(["user/message", "assistant/message", "tool/result"]);
207
227
  function isReplacementSurfaceEvent(event) {
@@ -411,29 +431,51 @@ return {
411
431
  });
412
432
  }
413
433
  var SHADOW_SAFETY_RATIO = 0.4;
414
- function useHiddenKeys(useSession, shadowedSeqs) {
434
+ function hiddenKeysFor(shadowedSeqs, nodes) {
435
+ if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null;
436
+ const hidden = new Set(shadowedSeqs);
437
+ const keys = [];
438
+ for (const node of nodes.values()) {
439
+ if (node.kind === "recall-marker") continue;
440
+ if (node.kind === "turn-tail") {
441
+ const closingSeq = node.data?.closing?.finalNode?.seq;
442
+ if (typeof closingSeq === "number" && hidden.has(closingSeq)) keys.push(node.key);
443
+ continue;
444
+ }
445
+ if (node.kind === "tool-call") {
446
+ const resultSeq = node.data?.root?.seq;
447
+ if (typeof resultSeq === "number" && hidden.has(resultSeq)) keys.push(node.key);
448
+ continue;
449
+ }
450
+ if (typeof node.anchorSeq === "number" && hidden.has(node.anchorSeq)) keys.push(node.key);
451
+ }
452
+ return keys.length === 0 ? null : keys;
453
+ }
454
+ var EMPTY_HIDE_PLAN = { degraded: false, hiddenFor: () => null };
455
+ function useMarkerHidePlan(useSession) {
415
456
  return useSession((snapshot) => {
416
- if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null;
417
- const hidden = new Set(shadowedSeqs);
418
- const keys = [];
457
+ const nodes = snapshot.chat.nodes;
419
458
  let rowCount = 0;
420
- for (const node of snapshot.chat.nodes.values()) {
459
+ const markers = [];
460
+ for (const node of nodes.values()) {
421
461
  if (typeof node.anchorSeq === "number") rowCount += 1;
422
- if (node.kind === "recall-marker") continue;
423
- if (node.kind === "turn-tail") {
424
- const closingSeq = node.data?.closing?.finalNode?.seq;
425
- if (typeof closingSeq === "number" && hidden.has(closingSeq)) keys.push(node.key);
426
- continue;
427
- }
428
- if (node.kind === "tool-call") {
429
- const resultSeq = node.data?.root?.seq;
430
- if (typeof resultSeq === "number" && hidden.has(resultSeq)) keys.push(node.key);
431
- continue;
462
+ if (node.kind === "recall-marker") {
463
+ markers.push({ key: node.key, shadowedSeqs: node.data?.shadowedSeqs });
432
464
  }
433
- if (typeof node.anchorSeq === "number" && hidden.has(node.anchorSeq)) keys.push(node.key);
434
465
  }
435
- if (keys.length > 0 && rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO) return null;
436
- return keys.length === 0 ? null : keys;
466
+ if (markers.length === 0) return EMPTY_HIDE_PLAN;
467
+ const union = /* @__PURE__ */ new Set();
468
+ const perMarker = /* @__PURE__ */ new Map();
469
+ for (const marker of markers) {
470
+ const keys = hiddenKeysFor(marker.shadowedSeqs, nodes);
471
+ perMarker.set(marker.key, keys);
472
+ if (keys !== null) for (const key of keys) union.add(key);
473
+ }
474
+ const degraded = rowCount > 0 && union.size / rowCount > SHADOW_SAFETY_RATIO;
475
+ return {
476
+ degraded,
477
+ hiddenFor: (key) => degraded ? null : perMarker.get(key) ?? null
478
+ };
437
479
  });
438
480
  }
439
481
  function useMarkerDismissed(useSession, markerSeq, op) {
@@ -635,7 +677,8 @@ return {
635
677
  function RecallMarkerRow({ node, useSession, t }) {
636
678
  const { seq, op, shadowedSeqs, legacy } = node.data;
637
679
  const dismissed = useMarkerDismissed(useSession, seq, op);
638
- const hiddenKeys = legacy || !getConfig().hideShadowed ? null : useHiddenKeys(useSession, shadowedSeqs);
680
+ const hidePlan = useMarkerHidePlan(useSession);
681
+ const hiddenKeys = legacy || !getConfig().hideShadowed ? null : hidePlan.hiddenFor(node.key);
639
682
  const css = hiddenKeys === null ? null : hiddenKeys.map((key) => `[data-chat-anchor-key=${JSON.stringify(key)}]{display:none!important}`).join("");
640
683
  const count = Array.isArray(shadowedSeqs) ? shadowedSeqs.length : 0;
641
684
  const label = op === "recall" ? count > 1 ? t("marker.recallMany", { count }) : t("marker.recallOne") : op === "regenerate" ? t("marker.regenerate") : t("marker.edit");
@@ -736,7 +779,8 @@ return {
736
779
  .dsh-rt-retention-input:focus{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
737
780
  .dsh-rt-option input{accent-color:var(--dsw-alias-state-business-primary)}
738
781
  /* ---- P1 timeline (conversation view tab) ---- */
739
- .dsh-rt-view{box-sizing:border-box;display:flex;flex-direction:column;gap:6px;height:100%;min-height:0;padding:12px 16px 0}
782
+
783
+ .dsh-rt-view{box-sizing:border-box;display:flex;flex-direction:column;gap:6px;flex:1 1 0%;min-height:0;overflow:hidden;padding:12px 16px 0}
740
784
  .dsh-rt-timeline-head{display:flex;align-items:center;gap:8px;flex:none}
741
785
  .dsh-rt-timeline-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px;flex:1}
742
786
  .dsh-rt-timeline-git{display:flex;align-items:center;gap:6px;flex:none;border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;padding:4px 8px}
@@ -773,7 +817,121 @@ return {
773
817
  .dsh-rt-modal-buttons{display:flex;justify-content:flex-end;gap:8px;flex:none}
774
818
  .dsh-rt-confirm{background:var(--dsw-alias-state-error-primary)}
775
819
  .dsh-rt-modal-json{margin:0;overflow:auto;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px;font-family:var(--dsw-font-mono);font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all}
820
+ .dsh-rt-fork-list{overflow-y:auto;flex:1;min-height:0;position:relative}
821
+ .dsh-rt-fork-spine-label{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;flex:none}
822
+ .dsh-rt-fork-row{position:absolute;left:0;right:0;box-sizing:border-box;display:flex;align-items:flex-start;gap:8px;padding:6px 8px;border-radius:8px}
823
+ .dsh-rt-fork-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
824
+ .dsh-rt-fork-icon{flex:none;width:22px;height:22px;display:inline-flex;justify-content:center;align-items:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
825
+ .dsh-rt-fork-boundary .dsh-rt-fork-icon{color:var(--dsw-alias-state-warning-primary)}
826
+ .dsh-rt-fork-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
827
+ .dsh-rt-fork-line{display:flex;align-items:baseline;gap:8px;min-width:0}
828
+ .dsh-rt-fork-label{font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-primary);white-space:nowrap}
829
+ .dsh-rt-fork-seq{font-family:var(--dsw-font-mono);font-size:11px;line-height:18px;color:var(--dsw-alias-label-caption);flex:none}
830
+ .dsh-rt-fork-shadowed{font-size:11px;line-height:18px;color:var(--dsw-alias-state-warning-primary);flex:none}
831
+ .dsh-rt-fork-text{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
832
+ .dsh-rt-fork-boundary{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated)}
833
+ .dsh-rt-fork-hist{display:flex;flex-direction:column;gap:4px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px;overflow-y:auto;flex:none}
834
+ .dsh-rt-fork-hist-row{display:flex;align-items:flex-start;gap:8px;padding:4px 8px;border-radius:8px}
835
+ .dsh-rt-fork-hist-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
776
836
  `;
837
+ var JUMP_PAGE_BUDGET = 24;
838
+ function switchToViewTab(viewId) {
839
+ const ORDER = { chat: 0, trajectory: 1, retrace: 2, "retrace-fork": 3 };
840
+ const index = ORDER[viewId];
841
+ if (index === void 0) {
842
+ console.warn(`[dsh-retrace] no tab order registered for "${viewId}"`);
843
+ return;
844
+ }
845
+ const buttons = [...document.querySelectorAll('[role="tablist"] [role="tab"]')];
846
+ const button = buttons[index];
847
+ if (!button) {
848
+ console.warn(`[dsh-retrace] tab "${viewId}" (index ${index}) not found in the conversation tab bar`);
849
+ return;
850
+ }
851
+ button.click();
852
+ }
853
+ function waitForElement(selector, frames) {
854
+ return new Promise((resolve) => {
855
+ let remaining = frames;
856
+ const probe = () => {
857
+ const el = document.querySelector(selector);
858
+ if (el !== null) return resolve(el);
859
+ if (remaining-- <= 0) return resolve(null);
860
+ requestAnimationFrame(probe);
861
+ };
862
+ requestAnimationFrame(probe);
863
+ });
864
+ }
865
+ function flashKey(key) {
866
+ const id = `dsh-rt-jump-${key.replace(/[^a-z0-9]/gi, "-")}`;
867
+ if (document.querySelector(`style[data-plugin-css="${id}"]`)) return;
868
+ const tag = document.createElement("style");
869
+ tag.dataset.pluginCss = id;
870
+ tag.textContent = `[data-chat-anchor-key=${JSON.stringify(key)}]{animation:dsh-rt-flash 1.6s ease-out 2}@keyframes dsh-rt-flash{0%,100%{background:transparent}30%,70%{background:var(--dsw-alias-state-business-primary)}55%{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent)}}`;
871
+ document.head.appendChild(tag);
872
+ setTimeout(() => tag.remove(), 3400);
873
+ }
874
+ async function jumpToAnchor(store, anchorSeq) {
875
+ switchToViewTab("chat");
876
+ if (!store || typeof store.loadOlder !== "function" || typeof store.getSnapshot !== "function") return;
877
+ const keyOfSeq = (seq) => {
878
+ const nodes = store.getSnapshot()?.chat?.nodes;
879
+ if (!nodes) return null;
880
+ for (const node of nodes.values()) {
881
+ if (typeof node.anchorSeq === "number" && node.anchorSeq === seq) return node.key;
882
+ }
883
+ return null;
884
+ };
885
+ let key = keyOfSeq(anchorSeq);
886
+ let pages = 0;
887
+ while (key === null && pages < JUMP_PAGE_BUDGET && store.hasMore) {
888
+ const before = store.getSnapshot()?.chat?.nodes?.size ?? 0;
889
+ await store.loadOlder();
890
+ const after = store.getSnapshot()?.chat?.nodes?.size ?? 0;
891
+ pages += 1;
892
+ if (after === before) break;
893
+ key = keyOfSeq(anchorSeq);
894
+ }
895
+ if (key === null) {
896
+ await new Promise((resolve) => setTimeout(resolve, 60));
897
+ key = keyOfSeq(anchorSeq);
898
+ }
899
+ if (key === null) {
900
+ console.warn(`[dsh-retrace] jump target seq ${anchorSeq} not reached within ${JUMP_PAGE_BUDGET} pages`);
901
+ return;
902
+ }
903
+ const el = await waitForElement(`[data-chat-anchor-key=${JSON.stringify(key)}]`, 90);
904
+ if (el === null) return;
905
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
906
+ flashKey(key);
907
+ }
908
+ var NODE_ICONS = { "user/message": "\u{1F464}", "assistant/message": "\u{1F916}", "tool/result": "\u{1F527}" };
909
+ function nodeTypeLabel(type, t) {
910
+ if (type === "user/message") return t("fork.node.user");
911
+ if (type === "assistant/message") return t("fork.node.assistant");
912
+ if (type === "tool/result") return t("fork.node.tool");
913
+ return type;
914
+ }
915
+ function bindListHeight(listEl) {
916
+ if (!listEl) return () => {
917
+ };
918
+ const measure = () => {
919
+ const top = listEl.getBoundingClientRect().top;
920
+ const height = Math.max(120, window.innerHeight - top - 16);
921
+ if (Math.abs(listEl.clientHeight - height) > 4) {
922
+ listEl.style.flex = "none";
923
+ listEl.style.height = `${height}px`;
924
+ }
925
+ };
926
+ measure();
927
+ const ro = typeof ResizeObserver === "function" ? new ResizeObserver(measure) : null;
928
+ ro?.observe(document.body);
929
+ window.addEventListener("resize", measure);
930
+ return () => {
931
+ ro?.disconnect();
932
+ window.removeEventListener("resize", measure);
933
+ };
934
+ }
777
935
  function ensureStyle() {
778
936
  if (typeof document === "undefined") return () => {
779
937
  };
@@ -841,63 +999,7 @@ return {
841
999
  if (projected === void 0) refresh();
842
1000
  refreshGit();
843
1001
  }, []);
844
- const JUMP_PAGE_BUDGET = 24;
845
- const jump = async (boundarySeq) => {
846
- actions?.setView?.("chat");
847
- const target = store;
848
- if (!target || typeof target.loadOlder !== "function" || typeof target.getSnapshot !== "function") return;
849
- const keyOfSeq = (seq) => {
850
- const nodes = target.getSnapshot()?.chat?.nodes;
851
- if (!nodes) return null;
852
- for (const node of nodes.values()) {
853
- if (typeof node.anchorSeq === "number" && node.anchorSeq === seq) return node.key;
854
- }
855
- return null;
856
- };
857
- let key = keyOfSeq(boundarySeq);
858
- let pages = 0;
859
- while (key === null && pages < JUMP_PAGE_BUDGET && target.hasMore) {
860
- const before = target.getSnapshot()?.chat?.nodes?.size ?? 0;
861
- await target.loadOlder();
862
- const after = target.getSnapshot()?.chat?.nodes?.size ?? 0;
863
- pages += 1;
864
- if (after === before) break;
865
- key = keyOfSeq(boundarySeq);
866
- }
867
- if (key === null) {
868
- await new Promise((resolve) => setTimeout(resolve, 60));
869
- key = keyOfSeq(boundarySeq);
870
- }
871
- if (key === null) {
872
- console.warn(`[dsh-retrace] jump target seq ${boundarySeq} not reached within ${JUMP_PAGE_BUDGET} pages`);
873
- return;
874
- }
875
- const el = await waitForElement(`[data-chat-anchor-key=${JSON.stringify(key)}]`, 90);
876
- if (el === null) return;
877
- el.scrollIntoView({ behavior: "smooth", block: "center" });
878
- flashKey(key);
879
- };
880
- function waitForElement(selector, frames) {
881
- return new Promise((resolve) => {
882
- let remaining = frames;
883
- const probe = () => {
884
- const el = document.querySelector(selector);
885
- if (el !== null) return resolve(el);
886
- if (remaining-- <= 0) return resolve(null);
887
- requestAnimationFrame(probe);
888
- };
889
- requestAnimationFrame(probe);
890
- });
891
- }
892
- function flashKey(key) {
893
- const id = `dsh-rt-jump-${key.replace(/[^a-z0-9]/gi, "-")}`;
894
- if (document.querySelector(`style[data-plugin-css="${id}"]`)) return;
895
- const tag = document.createElement("style");
896
- tag.dataset.pluginCss = id;
897
- tag.textContent = `[data-chat-anchor-key=${JSON.stringify(key)}]{animation:dsh-rt-flash 1.6s ease-out 2}@keyframes dsh-rt-flash{0%,100%{background:transparent}30%,70%{background:var(--dsw-alias-state-business-primary)}55%{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent)}}`;
898
- document.head.appendChild(tag);
899
- setTimeout(() => tag.remove(), 3400);
900
- }
1002
+ const jump = (boundarySeq) => jumpToAnchor(store, boundarySeq);
901
1003
  const requestPreview = (record) => {
902
1004
  setPreviewScope("both");
903
1005
  setPreview({ versionId: record.versionId, kind: record.kind, boundarySeq: record.boundarySeq, data: null, error: null });
@@ -930,6 +1032,10 @@ return {
930
1032
  const ROW_H = 64;
931
1033
  const list = versions ?? [];
932
1034
  const visible = list.slice(Math.max(0, Math.floor(scrollTop / ROW_H) - 2), Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2));
1035
+ (0, import_react.useEffect)(() => {
1036
+ if (list.length === 0) return void 0;
1037
+ return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-timeline-list"));
1038
+ }, [list.length]);
933
1039
  return (0, import_react.createElement)("div", { className: "dsh-rt-view" }, [
934
1040
  (0, import_react.createElement)("div", { key: "head", className: "dsh-rt-timeline-head" }, [
935
1041
  (0, import_react.createElement)("span", { key: "title", className: "dsh-rt-timeline-title" }, t("timeline.title")),
@@ -967,7 +1073,7 @@ return {
967
1073
  t,
968
1074
  top: list.indexOf(record) * ROW_H,
969
1075
  onPreview: () => requestPreview(record),
970
- onTrajectory: () => actions?.setView?.("trajectory"),
1076
+ onTrajectory: () => switchToViewTab("trajectory"),
971
1077
  onJump: () => jump(record.boundarySeq)
972
1078
  }))
973
1079
  ])
@@ -1050,6 +1156,135 @@ return {
1050
1156
  ])
1051
1157
  ]);
1052
1158
  }
1159
+ function ForkView({ sessionId, useProjection, t, actions, store }) {
1160
+ const [fork, setFork] = (0, import_react.useState)(null);
1161
+ const [loading, setLoading] = (0, import_react.useState)(false);
1162
+ const [error, setError] = (0, import_react.useState)(null);
1163
+ const [scrollTop, setScrollTop] = (0, import_react.useState)(0);
1164
+ const projected = typeof useProjection === "function" ? useProjection("retrace/forkmap") : void 0;
1165
+ const versions = typeof useProjection === "function" ? useProjection("retrace/versions") : void 0;
1166
+ const markerBySeq = new Map((versions?.versions ?? []).map((v) => [v.boundarySeq, v.markerText]));
1167
+ (0, import_react.useEffect)(() => {
1168
+ if (projected && Array.isArray(projected.nodes) && Array.isArray(projected.boundaries)) setFork(projected);
1169
+ }, [projected]);
1170
+ const refresh = () => {
1171
+ setLoading(true);
1172
+ setError(null);
1173
+ timelineGet(`/forkmap?sessionId=${encodeURIComponent(sessionId)}`).then((result) => {
1174
+ if (!result || result.ok !== true) throw new Error(result?.error?.message ?? "forkmap failed");
1175
+ setFork(result.value?.nodes ? result.value : null);
1176
+ }).catch((cause) => setError(cause?.message ?? "forkmap error")).finally(() => setLoading(false));
1177
+ };
1178
+ (0, import_react.useEffect)(() => {
1179
+ if (projected === void 0) refresh();
1180
+ }, []);
1181
+ const nodes = fork?.nodes ?? [];
1182
+ const boundaries = fork?.boundaries ?? [];
1183
+ const boundaryBySeq = new Map(boundaries.map((b) => [b.seq, b]));
1184
+ const spineSeqSet = new Set(nodes.map((n) => n.seq));
1185
+ const offSpine = boundaries.filter((b) => !spineSeqSet.has(b.seq));
1186
+ (0, import_react.useEffect)(() => {
1187
+ if (nodes.length === 0) return void 0;
1188
+ return bindListHeight(document.querySelector(".dsh-rt-view .dsh-rt-fork-list"));
1189
+ }, [nodes.length]);
1190
+ const ROW_H = 56;
1191
+ const visible = nodes.slice(
1192
+ Math.max(0, Math.floor(scrollTop / ROW_H) - 2),
1193
+ Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2)
1194
+ );
1195
+ return (0, import_react.createElement)("div", { className: "dsh-rt-view" }, [
1196
+ (0, import_react.createElement)("div", { key: "head", className: "dsh-rt-timeline-head" }, [
1197
+ (0, import_react.createElement)("span", { key: "title", className: "dsh-rt-timeline-title" }, t("fork.title")),
1198
+ (0, import_react.createElement)("button", {
1199
+ key: "refresh",
1200
+ type: "button",
1201
+ className: "dsh-rt-chip",
1202
+ onClick: refresh
1203
+ }, t("fork.refresh"))
1204
+ ]),
1205
+ error !== null && (0, import_react.createElement)("div", { key: "error", className: "dsh-rt-error" }, error),
1206
+ loading && (0, import_react.createElement)("div", { key: "loading", className: "dsh-rt-timeline-empty" }, t("timeline.loading")),
1207
+ !loading && nodes.length === 0 && (0, import_react.createElement)("div", { key: "empty", className: "dsh-rt-timeline-empty" }, t("fork.empty")),
1208
+ nodes.length > 0 && (0, import_react.createElement)("div", { key: "spine", className: "dsh-rt-fork-spine-label" }, t("fork.spine")),
1209
+ nodes.length > 0 && (0, import_react.createElement)("div", {
1210
+ key: "list",
1211
+ className: "dsh-rt-fork-list",
1212
+ onScroll: (event) => setScrollTop(event.target.scrollTop)
1213
+ }, [
1214
+ (0, import_react.createElement)("div", { key: "spacer", style: { height: `${nodes.length * ROW_H}px`, position: "relative" } }, [
1215
+ visible.map((node) => (0, import_react.createElement)(ForkRow, {
1216
+ key: node.seq,
1217
+ node,
1218
+ boundary: boundaryBySeq.get(node.seq),
1219
+ markerText: markerBySeq.get(node.seq),
1220
+ t,
1221
+ top: nodes.indexOf(node) * ROW_H,
1222
+ onJump: () => jumpToAnchor(store, node.seq)
1223
+ }))
1224
+ ])
1225
+ ]),
1226
+ offSpine.length > 0 && (0, import_react.createElement)("div", { key: "hist", className: "dsh-rt-fork-hist" }, [
1227
+ (0, import_react.createElement)("div", { key: "title", className: "dsh-rt-fork-spine-label" }, t("fork.histTitle")),
1228
+ offSpine.map((boundary) => (0, import_react.createElement)(ForkHistRow, {
1229
+ key: boundary.seq,
1230
+ boundary,
1231
+ markerText: markerBySeq.get(boundary.seq),
1232
+ t,
1233
+ onJump: () => jumpToAnchor(store, boundary.seq)
1234
+ }))
1235
+ ])
1236
+ ]);
1237
+ }
1238
+ function ForkRow({ node, boundary, markerText, t, top, onJump }) {
1239
+ const isBoundary = boundary !== void 0;
1240
+ const icon = isBoundary ? KIND_ICONS[boundary.kind] ?? "\u2022" : NODE_ICONS[node.type] ?? "\u2022";
1241
+ const label = isBoundary ? kindLabel(boundary.kind, t) : nodeTypeLabel(node.type, t);
1242
+ return (0, import_react.createElement)("div", {
1243
+ className: `dsh-rt-fork-row${isBoundary ? " dsh-rt-fork-boundary" : ""}`,
1244
+ style: { top: `${top}px` }
1245
+ }, [
1246
+ (0, import_react.createElement)("span", { key: "icon", className: "dsh-rt-fork-icon" }, icon),
1247
+ (0, import_react.createElement)("div", { key: "body", className: "dsh-rt-fork-body" }, [
1248
+ (0, import_react.createElement)("div", { key: "line", className: "dsh-rt-fork-line" }, [
1249
+ (0, import_react.createElement)("span", { key: "label", className: "dsh-rt-fork-label" }, label),
1250
+ (0, import_react.createElement)("span", { key: "seq", className: "dsh-rt-fork-seq" }, `#${node.seq}`),
1251
+ isBoundary && boundary.replacedSeqs.length > 0 && (0, import_react.createElement)(
1252
+ "span",
1253
+ { key: "shadowed", className: "dsh-rt-fork-shadowed" },
1254
+ t("fork.shadowed", { count: boundary.replacedSeqs.length })
1255
+ )
1256
+ ]),
1257
+ isBoundary && markerText && (0, import_react.createElement)(
1258
+ "div",
1259
+ { key: "text", className: "dsh-rt-fork-text" },
1260
+ markerText.length > 140 ? `${markerText.slice(0, 140)}\u2026` : markerText
1261
+ )
1262
+ ]),
1263
+ (0, import_react.createElement)("button", { key: "jump", type: "button", className: "dsh-rt-chip", onClick: onJump }, t("timeline.jump"))
1264
+ ]);
1265
+ }
1266
+ function ForkHistRow({ boundary, markerText, t, onJump }) {
1267
+ return (0, import_react.createElement)("div", { className: "dsh-rt-fork-hist-row" }, [
1268
+ (0, import_react.createElement)("span", { key: "icon", className: "dsh-rt-fork-icon" }, KIND_ICONS[boundary.kind] ?? "\u2022"),
1269
+ (0, import_react.createElement)("div", { key: "body", className: "dsh-rt-fork-body" }, [
1270
+ (0, import_react.createElement)("div", { key: "line", className: "dsh-rt-fork-line" }, [
1271
+ (0, import_react.createElement)("span", { key: "label", className: "dsh-rt-fork-label" }, kindLabel(boundary.kind, t)),
1272
+ (0, import_react.createElement)("span", { key: "seq", className: "dsh-rt-fork-seq" }, `#${boundary.seq}`),
1273
+ boundary.replacedSeqs.length > 0 && (0, import_react.createElement)(
1274
+ "span",
1275
+ { key: "shadowed", className: "dsh-rt-fork-shadowed" },
1276
+ t("fork.shadowed", { count: boundary.replacedSeqs.length })
1277
+ )
1278
+ ]),
1279
+ markerText && (0, import_react.createElement)(
1280
+ "div",
1281
+ { key: "text", className: "dsh-rt-fork-text" },
1282
+ markerText.length > 140 ? `${markerText.slice(0, 140)}\u2026` : markerText
1283
+ )
1284
+ ]),
1285
+ (0, import_react.createElement)("button", { key: "jump", type: "button", className: "dsh-rt-chip", onClick: onJump }, t("timeline.jump"))
1286
+ ]);
1287
+ }
1053
1288
  function apply(ctx) {
1054
1289
  const disposeStyle = ensureStyle();
1055
1290
  ctx.effect(() => () => disposeStyle(), "dsh-retrace: styles");
@@ -1093,6 +1328,17 @@ return {
1093
1328
  store: ctx.get?.("sessions")?.binding?.(sessionId)?.session
1094
1329
  })
1095
1330
  }, RetraceView));
1331
+ ctx.slots.inject("conversation.view", () => ctx.slots.register({
1332
+ name: "conversation.view",
1333
+ id: "retrace-fork",
1334
+ order: 30,
1335
+ locale: NS,
1336
+ label: () => t("view.fork"),
1337
+ inject: (sessionId, actions) => ({
1338
+ actions,
1339
+ store: ctx.get?.("sessions")?.binding?.(sessionId)?.session
1340
+ })
1341
+ }, ForkView));
1096
1342
  ctx.slots.inject("settings.general.item", () => ctx.slots.register({
1097
1343
  name: "settings.general.item",
1098
1344
  id: "retrace",
package/lib/forkmap.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * dsh-retrace — `retrace/forkmap` pure fold (PLAN §5.2, P2.1).
3
+ *
4
+ * The fork map is the BRANCH-TOPOLOGY presentation layer ("how we got here"):
5
+ * every surface replacement — recall / edit / regenerate / restore markers,
6
+ * compaction checkpoints, and any other replace — is a FORK POINT that shadows
7
+ * a range of the old path and continues a new path. This module mirrors the
8
+ * official `foldSurface` state transition (the same one the versions unit
9
+ * uses) and additionally records each boundary with the seqs it shadowed, so
10
+ * the client can render:
11
+ *
12
+ * spine — the current-surface node flow (markers sit ON the spine),
13
+ * branches — at each boundary, the shadowed (old-path) node cluster.
14
+ *
15
+ * Data notes (verified against the official source, 2026-08-26):
16
+ * - replace `start`/`end` are EVENT SEQS (not surface indices); the shadowed
17
+ * seqs are the surface nodes the splice removed — exactly
18
+ * `foldSurface(...).replacements[].shadowedSeqs`.
19
+ * - official `traceEvent` does NOT link a marker to its new path
20
+ * (new-path events never cite the marker, so `derivedEventSeqs` is empty)
21
+ * — the new path IS the spine: the surface nodes after the boundary
22
+ * (PLAN.md:295 "标记→新路径无需自解析" corrected by research).
23
+ * - boundaries ARE the versions (every replacement closes a version); the
24
+ * wire stays lean (no markerText — the client joins it from
25
+ * `retrace/versions` by boundarySeq).
26
+ *
27
+ * The module is import-free except for the shared boundary predicates, stays
28
+ * transport-neutral, and is registered alongside `retrace/versions` in the
29
+ * versioning seam. No side effects: pure fold only.
30
+ */
31
+ import {
32
+ classifyBoundaryKind,
33
+ isReplacementSurfaceEvent,
34
+ isSurfaceEvent,
35
+ } from './version-index.js'
36
+
37
+ /** Fork-map fold state (plain JSON — durable checkpoint rows validate it). */
38
+ export function createForkmapState() {
39
+ return {
40
+ /** Current surface nodes: `{ seq, type }` in fold order (mirror foldSurface). */
41
+ nodes: [],
42
+ /** Replacement boundaries in append order. */
43
+ boundaries: [],
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Fold one committed event into the fork-map state.
49
+ *
50
+ * Performance contract (load-bearing for the projection registry): when the
51
+ * event changes nothing — no surface move, no boundary — the SAME state
52
+ * reference is returned (`Object.is` gates the change feed).
53
+ *
54
+ * @param {ReturnType<typeof createForkmapState>} state
55
+ * @param {object} event
56
+ * @returns the next state (same reference when nothing changed).
57
+ */
58
+ export function applyForkmap(state, event) {
59
+ if (!event || typeof event.seq !== 'number') return state
60
+ let next = state
61
+ let shadowed = null
62
+
63
+ // 1. Surface fold (mirror foldSurface / the versions unit).
64
+ if (isSurfaceEvent(event)) {
65
+ const op = event.surfaceOp
66
+ if (op === 'append') {
67
+ next = { ...next, nodes: [...next.nodes, { seq: event.seq, type: event.type }] }
68
+ } else if (op && op.op === 'replace') {
69
+ const startIdx = next.nodes.findIndex((node) => node.seq === op.start)
70
+ const endIdx = next.nodes.findIndex((node) => node.seq === op.end)
71
+ if (startIdx !== -1 && endIdx !== -1 && startIdx <= endIdx) {
72
+ shadowed = next.nodes.slice(startIdx, endIdx + 1).map((node) => node.seq)
73
+ const nodes = next.nodes.slice()
74
+ nodes.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, type: event.type })
75
+ next = { ...next, nodes }
76
+ }
77
+ }
78
+ }
79
+
80
+ // 2. Boundary (fork point). The shadowed range is the splice's removal when
81
+ // the span was live; otherwise fall back to the marker's own citation.
82
+ if (isReplacementSurfaceEvent(event)) {
83
+ const cited = Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs : []
84
+ const replacedSeqs = shadowed !== null ? shadowed : cited
85
+ const boundary = {
86
+ seq: event.seq,
87
+ kind: classifyBoundaryKind(event),
88
+ replacedSeqs,
89
+ }
90
+ next = { ...next, boundaries: [...next.boundaries, boundary] }
91
+ }
92
+
93
+ return next
94
+ }
95
+
96
+ /** The client-visible wire value (what `session/projection` frames serve). */
97
+ export function viewForkmap(state) {
98
+ return {
99
+ nodes: state.nodes.map((node) => ({ seq: node.seq, type: node.type })),
100
+ boundaries: state.boundaries.map((boundary) => ({
101
+ seq: boundary.seq,
102
+ kind: boundary.kind,
103
+ replacedSeqs: boundary.replacedSeqs,
104
+ })),
105
+ }
106
+ }
package/lib/http.js CHANGED
@@ -8,6 +8,9 @@
8
8
  * GET /api/plugins/retrace/versions?sessionId=
9
9
  * — live projection snapshot (HTTP fallback channel; the push channel is
10
10
  * `session/projection` frames via dsh-host-apiproxy).
11
+ * GET /api/plugins/retrace/forkmap?sessionId=
12
+ * — the fork-map projection snapshot (branch topology, P2.1; same
13
+ * push-frame + HTTP fallback dual channel as /versions).
11
14
  * GET /api/plugins/retrace/event?sessionId=&seq=&before=&after=
12
15
  * — one event + context window (sessionQuery.readEvent, lazy reads).
13
16
  * GET /api/plugins/retrace/surface?sessionId=
@@ -87,6 +90,15 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
87
90
  }
88
91
  }
89
92
 
93
+ function handleForkmap(req, res, sessionId, config) {
94
+ seam.setConfig(sessionId, config)
95
+ try {
96
+ sendJson(res, 200, { ok: true, value: seam.snapshotForkmap(sessionId) })
97
+ } catch (error) {
98
+ sendError(res, error)
99
+ }
100
+ }
101
+
90
102
  async function handleEvent(req, res, searchParams) {
91
103
  const sessionId = searchParams.get('sessionId') ?? ''
92
104
  const seq = Number(searchParams.get('seq'))
@@ -249,6 +261,8 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
249
261
  switch (op) {
250
262
  case 'versions':
251
263
  return handleVersions(req, res, sessionId, config)
264
+ case 'forkmap':
265
+ return handleForkmap(req, res, sessionId, config)
252
266
  case 'event':
253
267
  return void handleEvent(req, res, searchParams)
254
268
  case 'surface':