dsh-sidenote 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,8 @@ dsh plugin --profile web add dsh-sidenote
47
47
 
48
48
  侧聊有独立的多轮对话、工具卡、模型和权限切换,也支持图片、`@` 引用以及面板内审批。主线和支线各自推进。
49
49
 
50
+ 刷新或重启 DSH 后,侧聊自身的问答仍留在侧聊区,继承的主线上下文保持折叠分区。保存为正式会话保留这个分区;从保存的会话再次开侧聊时,才建立新的继承范围。来源尚未确认时可以阅读消息,确认后再使用回流。
51
+
50
52
  ### 有想法,直接落在原文上
51
53
 
52
54
  划选主对话或侧聊中的一段回复,写下问题或修改意见。**原文和注释一起保留**,可累积多条,发送前预览、移除,正文草稿照常编辑。
package/lib/client.js CHANGED
@@ -199,16 +199,18 @@ window.__ModuleLoader__.load({
199
199
  if (last?.type !== "compaction/start" || last.data.turn !== null) return void 0;
200
200
  return ((last.data.sourceCommandId === void 0 ? void 0 : entries.findLast(({ event }) => event.type === "command/run" && event.data.commandId === last.data.sourceCommandId)?.event)?.seq ?? last.seq) - 1;
201
201
  }
202
- /** Published event window, shared by all supported hosts. Its last seed marker
203
- * is the child’s own fork cut (earlier markers can belong to ancestor forks). */
202
+ /** Restore also appends end-seed, but only inherited=true records a fork cut.
203
+ * A saved copy resolves the cut before its original first-send snapshot. */
204
+ function inheritedBoundaryOf(entries, beforeSeq = Infinity) {
205
+ const seed = entries.findLast(({ event }) => event.type === "session/end-seed" && event.data?.inherited === true && event.seq < beforeSeq);
206
+ return seed === void 0 ? void 0 : seed.event.seq - 1;
207
+ }
208
+ /** The loaded window can lack the inherited marker; undefined means pending. */
204
209
  function inheritedBoundarySource(session) {
205
210
  const source = contextEventSource(session);
206
211
  return source && {
207
212
  subscribe: (notify) => source.subscribe(notify),
208
- getSnapshot: () => {
209
- const seed = source.getSnapshot().entries.findLast((entry) => entry.event.type === "session/end-seed");
210
- return seed === void 0 ? void 0 : seed.event.seq - 1;
211
- }
213
+ getSnapshot: () => inheritedBoundaryOf(source.getSnapshot().entries)
212
214
  };
213
215
  }
214
216
  /** Wait for the host's single-page pull before requesting a jump. loadThrough
@@ -740,7 +742,7 @@ window.__ModuleLoader__.load({
740
742
  let dockRegistered = false;
741
743
  let report = {
742
744
  versions: {
743
- sidenote: "0.8.0",
745
+ sidenote: "0.8.1",
744
746
  dsh: "unknown",
745
747
  betterSidebar: null
746
748
  },
@@ -1080,6 +1082,8 @@ window.__ModuleLoader__.load({
1080
1082
  expandAll: "Expand all",
1081
1083
  jumpToLatest: "↓ Jump to latest",
1082
1084
  reflowAll: "Reflow all",
1085
+ reflowPending: "Confirming this side chat’s origin before bringing replies back",
1086
+ sideRestoreMissing: "The original side chat could not be restored. Reopen it from the recently closed list in /side.",
1083
1087
  reflowAllTitle: "Bring all Q&A pairs back to the main session",
1084
1088
  reflowAllDone: "Reflowed ✓",
1085
1089
  promote: "Save as session",
@@ -1303,6 +1307,8 @@ window.__ModuleLoader__.load({
1303
1307
  expandAll: "全部展开",
1304
1308
  jumpToLatest: "↓ 跳到最新",
1305
1309
  reflowAll: "整段回流",
1310
+ reflowPending: "正在确认侧聊来源,确认后可回流",
1311
+ sideRestoreMissing: "未能恢复原侧聊。请从 /side 的最近关闭列表重新打开。",
1306
1312
  reflowAllTitle: "把该侧聊全部问答对带回主会话(chip 可预览/逐条撤)",
1307
1313
  reflowAllDone: "已回流 ✓",
1308
1314
  promote: "保存为正式会话",
@@ -1409,15 +1415,15 @@ window.__ModuleLoader__.load({
1409
1415
  document.head.appendChild(tag);
1410
1416
  }
1411
1417
  var diagnostics_module_css_default = {
1412
- "panel": "tGe0LW_panel",
1413
- "header": "tGe0LW_header",
1414
1418
  "list": "tGe0LW_list",
1415
- "description": "tGe0LW_description",
1419
+ "header": "tGe0LW_header",
1420
+ "actions": "tGe0LW_actions",
1421
+ "versions": "tGe0LW_versions",
1416
1422
  "trigger": "tGe0LW_trigger",
1417
1423
  "setting": "tGe0LW_setting",
1418
- "actions": "tGe0LW_actions",
1419
- "settingTitle": "tGe0LW_settingTitle",
1420
- "versions": "tGe0LW_versions"
1424
+ "panel": "tGe0LW_panel",
1425
+ "description": "tGe0LW_description",
1426
+ "settingTitle": "tGe0LW_settingTitle"
1421
1427
  };
1422
1428
  //#endregion
1423
1429
  //#region src/client/diagnostics/index.tsx
@@ -2363,33 +2369,33 @@ window.__ModuleLoader__.load({
2363
2369
  document.head.appendChild(tag);
2364
2370
  }
2365
2371
  var annotations_module_css_default = {
2366
- "chip": "SVnebq_chip",
2367
- "chipDismissShade": "SVnebq_chipDismissShade",
2368
- "chipClearAll": "SVnebq_chipClearAll",
2369
- "sentChipGroup": "SVnebq_sentChipGroup",
2370
- "contextAction": "SVnebq_contextAction",
2371
- "sentChip": "SVnebq_sentChip",
2372
- "sentChevron": "SVnebq_sentChevron",
2373
- "contextEditor": "SVnebq_contextEditor",
2374
- "contextLabel": "SVnebq_contextLabel",
2375
2372
  "contextFooter": "SVnebq_contextFooter",
2376
- "chipWrap": "SVnebq_chipWrap",
2377
- "userContextHost": "SVnebq_userContextHost",
2378
- "sentPreview": "SVnebq_sentPreview",
2379
- "contextContent": "SVnebq_contextContent",
2380
2373
  "contextText": "SVnebq_contextText",
2381
- "contextNumber": "SVnebq_contextNumber",
2382
- "contextActions": "SVnebq_contextActions",
2383
- "storageNotice": "SVnebq_storageNotice",
2384
- "contextItem": "SVnebq_contextItem",
2385
- "contextList": "SVnebq_contextList",
2386
2374
  "sentChips": "SVnebq_sentChips",
2375
+ "contextNumber": "SVnebq_contextNumber",
2376
+ "contextContent": "SVnebq_contextContent",
2387
2377
  "chipMain": "SVnebq_chipMain",
2388
- "sentChevronOpen": "SVnebq_sentChevronOpen",
2389
- "contextPanel": "SVnebq_contextPanel",
2378
+ "sentChip": "SVnebq_sentChip",
2379
+ "contextAction": "SVnebq_contextAction",
2380
+ "sentPreview": "SVnebq_sentPreview",
2381
+ "chipWrap": "SVnebq_chipWrap",
2382
+ "sentChipGroup": "SVnebq_sentChipGroup",
2390
2383
  "contextSource": "SVnebq_contextSource",
2384
+ "userContextHost": "SVnebq_userContextHost",
2385
+ "contextEditor": "SVnebq_contextEditor",
2386
+ "sentChevronOpen": "SVnebq_sentChevronOpen",
2387
+ "chipDismissShade": "SVnebq_chipDismissShade",
2388
+ "contextItem": "SVnebq_contextItem",
2389
+ "contextActions": "SVnebq_contextActions",
2390
+ "contextLabel": "SVnebq_contextLabel",
2391
+ "chipClearAll": "SVnebq_chipClearAll",
2392
+ "sentChevron": "SVnebq_sentChevron",
2393
+ "chip": "SVnebq_chip",
2391
2394
  "contextAppear": "SVnebq_contextAppear",
2392
- "chipDismiss": "SVnebq_chipDismiss"
2395
+ "contextPanel": "SVnebq_contextPanel",
2396
+ "storageNotice": "SVnebq_storageNotice",
2397
+ "chipDismiss": "SVnebq_chipDismiss",
2398
+ "contextList": "SVnebq_contextList"
2393
2399
  };
2394
2400
  //#endregion
2395
2401
  //#region src/client/annotations/NativeUserContext.tsx
@@ -2863,39 +2869,39 @@ window.__ModuleLoader__.load({
2863
2869
  document.head.appendChild(tag);
2864
2870
  }
2865
2871
  var sidechat_module_css_default = {
2866
- "contextChevronOpen": "_5KEEsG_contextChevronOpen",
2867
2872
  "emptyIcon": "_5KEEsG_emptyIcon",
2868
- "progressEntry": "_5KEEsG_progressEntry",
2869
- "visibilitySetting": "_5KEEsG_visibilitySetting",
2870
- "progressProcess": "_5KEEsG_progressProcess",
2871
- "progressPreview": "_5KEEsG_progressPreview",
2872
- "historyToggle": "_5KEEsG_historyToggle",
2873
- "progressContent": "_5KEEsG_progressContent",
2874
- "nativeConversation": "_5KEEsG_nativeConversation",
2873
+ "progressDetail": "_5KEEsG_progressDetail",
2874
+ "mainContext": "_5KEEsG_mainContext",
2875
+ "contextImage": "_5KEEsG_contextImage",
2876
+ "headerSide": "_5KEEsG_headerSide",
2875
2877
  "contextBubble": "_5KEEsG_contextBubble",
2876
- "nativeToolbar": "_5KEEsG_nativeToolbar",
2877
- "emptyTitle": "_5KEEsG_emptyTitle",
2878
+ "progressEntries": "_5KEEsG_progressEntries",
2879
+ "progressLatest": "_5KEEsG_progressLatest",
2880
+ "stateScreen": "_5KEEsG_stateScreen",
2881
+ "historyToggle": "_5KEEsG_historyToggle",
2882
+ "progress": "_5KEEsG_progress",
2883
+ "progressProcess": "_5KEEsG_progressProcess",
2884
+ "reflowAction": "_5KEEsG_reflowAction",
2878
2885
  "nativeHost": "_5KEEsG_nativeHost",
2879
- "stateDetail": "_5KEEsG_stateDetail",
2880
- "headerSide": "_5KEEsG_headerSide",
2886
+ "contextColumn": "_5KEEsG_contextColumn",
2887
+ "emptyText": "_5KEEsG_emptyText",
2888
+ "nativeToolbar": "_5KEEsG_nativeToolbar",
2889
+ "visibilitySetting": "_5KEEsG_visibilitySetting",
2890
+ "root": "_5KEEsG_root",
2891
+ "chatView": "_5KEEsG_chatView",
2881
2892
  "contextChevron": "_5KEEsG_contextChevron",
2882
2893
  "contextHint": "_5KEEsG_contextHint",
2883
- "contextUserRow": "_5KEEsG_contextUserRow",
2884
- "progress": "_5KEEsG_progress",
2894
+ "progressContent": "_5KEEsG_progressContent",
2885
2895
  "errorRow": "_5KEEsG_errorRow",
2886
- "nativeToolbarActions": "_5KEEsG_nativeToolbarActions",
2887
- "reflowAction": "_5KEEsG_reflowAction",
2896
+ "contextUserRow": "_5KEEsG_contextUserRow",
2897
+ "progressEntry": "_5KEEsG_progressEntry",
2898
+ "progressPreview": "_5KEEsG_progressPreview",
2899
+ "emptyTitle": "_5KEEsG_emptyTitle",
2900
+ "nativeConversation": "_5KEEsG_nativeConversation",
2901
+ "contextChevronOpen": "_5KEEsG_contextChevronOpen",
2888
2902
  "nativeAction": "_5KEEsG_nativeAction",
2889
- "root": "_5KEEsG_root",
2890
- "progressLatest": "_5KEEsG_progressLatest",
2891
- "emptyText": "_5KEEsG_emptyText",
2892
- "progressEntries": "_5KEEsG_progressEntries",
2893
- "progressDetail": "_5KEEsG_progressDetail",
2894
- "mainContext": "_5KEEsG_mainContext",
2895
- "chatView": "_5KEEsG_chatView",
2896
- "contextColumn": "_5KEEsG_contextColumn",
2897
- "contextImage": "_5KEEsG_contextImage",
2898
- "stateScreen": "_5KEEsG_stateScreen"
2903
+ "nativeToolbarActions": "_5KEEsG_nativeToolbarActions",
2904
+ "stateDetail": "_5KEEsG_stateDetail"
2899
2905
  };
2900
2906
  //#endregion
2901
2907
  //#region src/client/sidechat/ContextProgress.tsx
@@ -3185,38 +3191,53 @@ window.__ModuleLoader__.load({
3185
3191
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3186
3192
  className: sidechat_module_css_default.contextColumn,
3187
3193
  "data-sidenote-main-context": true,
3188
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3189
- type: "button",
3190
- className: sidechat_module_css_default.historyToggle,
3191
- "aria-expanded": expanded,
3192
- "aria-controls": contextId,
3193
- onClick: () => {
3194
- fold.toggle("inherited");
3195
- },
3196
- children: [
3197
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutline14, {
3198
- size: 12,
3199
- className: expanded ? sidechat_module_css_default.contextChevronOpen : sidechat_module_css_default.contextChevron
3200
- }),
3201
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("mainContext") }),
3202
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3203
- className: sidechat_module_css_default.contextHint,
3204
- children: context.contextPending ? t("contextAtSend") : context.progress?.takenAt ? t("contextAsOf", { time: context.progress.takenAt }) : t("contextInherited")
3205
- })
3206
- ]
3207
- }), expanded && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextProgress, {
3208
- id: contextId,
3209
- history: context.history,
3210
- entries: context.progress?.entries ?? [],
3211
- live: context.contextPending === true,
3212
- loading,
3213
- error,
3214
- onRetry: () => {
3215
- setAttempt((value) => value + 1);
3216
- },
3217
- loadImage: context.loadImage,
3218
- loadProgressImage: context.loadProgressImage
3219
- })]
3194
+ children: [
3195
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3196
+ type: "button",
3197
+ className: sidechat_module_css_default.historyToggle,
3198
+ "aria-expanded": expanded,
3199
+ "aria-controls": contextId,
3200
+ onClick: () => {
3201
+ fold.toggle("inherited");
3202
+ },
3203
+ children: [
3204
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutline14, {
3205
+ size: 12,
3206
+ className: expanded ? sidechat_module_css_default.contextChevronOpen : sidechat_module_css_default.contextChevron
3207
+ }),
3208
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("mainContext") }),
3209
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3210
+ className: sidechat_module_css_default.contextHint,
3211
+ children: context.contextPending ? t("contextAtSend") : context.progress?.takenAt ? t("contextAsOf", { time: context.progress.takenAt }) : t("contextInherited")
3212
+ })
3213
+ ]
3214
+ }),
3215
+ !expanded && context.loadError && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3216
+ className: sidechat_module_css_default.errorRow,
3217
+ role: "alert",
3218
+ children: [context.loadError, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3219
+ type: "button",
3220
+ className: sidechat_module_css_default.nativeAction,
3221
+ onClick: () => {
3222
+ fold.toggle("inherited");
3223
+ },
3224
+ children: t("contextRetry")
3225
+ })]
3226
+ }),
3227
+ expanded && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextProgress, {
3228
+ id: contextId,
3229
+ history: context.history,
3230
+ entries: context.progress?.entries ?? [],
3231
+ live: context.contextPending === true,
3232
+ loading,
3233
+ error: error ?? (loading ? void 0 : context.loadError),
3234
+ onRetry: () => {
3235
+ setAttempt((value) => value + 1);
3236
+ },
3237
+ loadImage: context.loadImage,
3238
+ loadProgressImage: context.loadProgressImage
3239
+ })
3240
+ ]
3220
3241
  })
3221
3242
  });
3222
3243
  }
@@ -3544,16 +3565,19 @@ ${body}
3544
3565
  function contextHistory(history, progress) {
3545
3566
  return progress.some((entry) => entry.kind === "checkpoint") ? [] : history;
3546
3567
  }
3547
- /** Saving adds a new end-seed; the first-send cut must NOT move to that seed.
3548
- * Older snapshots lack an explicit cut, so use the seed preceding their input. */
3549
- function savedContextOf(nodes, events, hasMore = false) {
3550
- for (const value of [...nodes].reverse()) {
3568
+ /** Saving preserves the latest side chat's reading cut. An active side chat
3569
+ * freezes its first own snapshot after afterSeq. Restore markers never establish ownership. */
3570
+ function savedContextOf(nodes, events, hasMore = false, afterSeq) {
3571
+ for (const value of afterSeq === void 0 ? [...nodes].reverse() : nodes) {
3551
3572
  const node = value;
3552
- if (node.kind !== "user" && node.kind !== "steering") continue;
3573
+ if (node.seq <= (afterSeq ?? -1) || node.kind !== "user" && node.kind !== "steering") continue;
3553
3574
  const saved = splitSnapshotPrefix(node.content?.find((block) => block.type === "text")?.text ?? "");
3554
3575
  if (!saved) continue;
3555
- if (saved.boundarySeq === void 0 && hasMore && !events.some(({ event }) => event.type === "session/end-seed" && event.seq < node.seq)) continue;
3556
- const boundarySeq = saved.boundarySeq ?? (events.findLast(({ event }) => event.type === "session/end-seed" && event.seq < node.seq)?.event.seq ?? 0) - 1;
3576
+ let boundarySeq = saved.boundarySeq ?? inheritedBoundaryOf(events, node.seq);
3577
+ if (boundarySeq === void 0) {
3578
+ if (hasMore || events.some(({ event }) => event.type === "session/end-seed" && event.seq < node.seq)) continue;
3579
+ boundarySeq = -1;
3580
+ }
3557
3581
  if (!Number.isSafeInteger(boundarySeq) || boundarySeq < -1 || boundarySeq >= node.seq) continue;
3558
3582
  return {
3559
3583
  ...saved,
@@ -3640,7 +3664,12 @@ ${body}
3640
3664
  (0, react.useEffect)(() => {
3641
3665
  if (typeof markerSeq !== "number" || saved) return;
3642
3666
  let active = true;
3643
- loadInheritedContext(session, markerSeq).catch((reason) => {
3667
+ loadInheritedContext(session, markerSeq).then(async () => {
3668
+ const window = source?.getSnapshot();
3669
+ const nodes = chat?.getLegacy()?.nodes ?? [];
3670
+ const prefix = splitSnapshotPrefix(nodes.find((value) => value.seq === markerSeq)?.content?.find((block) => block.type === "text")?.text ?? "");
3671
+ if (prefix && prefix.boundarySeq === void 0 && !savedContextOf(nodes, window?.entries ?? [], window?.hasMore) && window?.hasMore) await loadInheritedContext(session, 0);
3672
+ }).catch((reason) => {
3644
3673
  if (active) setError(String(reason instanceof Error ? reason.message : reason));
3645
3674
  });
3646
3675
  return () => {
@@ -3649,7 +3678,9 @@ ${body}
3649
3678
  }, [
3650
3679
  markerSeq,
3651
3680
  !!saved,
3652
- session
3681
+ session,
3682
+ source,
3683
+ chat
3653
3684
  ]);
3654
3685
  const progress = (0, react.useMemo)(() => saved ? {
3655
3686
  entries: snapshotEntriesFromText(saved.snapshot, saved.content),
@@ -3837,7 +3868,8 @@ ${body}
3837
3868
  }, { slots: { views: ChatView } });
3838
3869
  }
3839
3870
  /** Only completed, fork-local final answers are eligible for reflow. */
3840
- function finalReplies(messages, boundarySeq = -1, completed) {
3871
+ function finalReplies(messages, boundarySeq, completed) {
3872
+ if (boundarySeq === void 0) return [];
3841
3873
  const replies = [];
3842
3874
  let final;
3843
3875
  const finish = () => {
@@ -3859,6 +3891,7 @@ ${body}
3859
3891
  return side.reflow.list(side.parentSessionId).some((item) => item.sourceKey === key || item.sourceKey === void 0 && item.sideTitle === side.title && item.text === message.text && item.question === question);
3860
3892
  }
3861
3893
  function addReply(side, message, question) {
3894
+ if (side.boundarySeq === void 0 || message.seq === void 0 || message.seq <= side.boundarySeq) return;
3862
3895
  if (!isReplyAdded(side, message, question)) side.reflow.add(side.parentSessionId, side.title, message.text, question, `${side.sessionId}:${message.seq ?? message.key}`);
3863
3896
  }
3864
3897
  function ReflowAction(props) {
@@ -4042,15 +4075,11 @@ ${body}
4042
4075
  const accept = (0, react.useCallback)(() => {
4043
4076
  if (sending.current) setAccepted(sending.current);
4044
4077
  }, []);
4045
- const saved = (0, react.useMemo)(() => boundarySeq === void 0 ? void 0 : (legacy?.nodes ?? []).flatMap((value) => {
4046
- const node = value;
4047
- if (!["user", "steering"].includes(node.kind) || node.seq <= boundarySeq) return [];
4048
- const prefix = splitSnapshotPrefix(node.content?.find((block) => block.type === "text")?.text ?? "");
4049
- return prefix ? [{
4050
- ...prefix,
4051
- content: node.content
4052
- }] : [];
4053
- })[0], [legacy, boundarySeq]) ?? accepted;
4078
+ const saved = (0, react.useMemo)(() => boundarySeq === void 0 ? void 0 : savedContextOf(legacy?.nodes ?? [], childWindow?.entries ?? [], childWindow?.hasMore, boundarySeq), [
4079
+ legacy,
4080
+ childWindow,
4081
+ boundarySeq
4082
+ ]) ?? accepted;
4054
4083
  const preview = forkedMidTurn && !saved;
4055
4084
  const parent = preview ? ctx.sessions.binding(parentSessionId)?.session : void 0;
4056
4085
  const parentChat = (0, react.useMemo)(() => preview ? chatSourceOf(ctx, ctx.sessions.binding(parentSessionId)) : void 0, [
@@ -4318,6 +4347,16 @@ ${body}
4318
4347
  function storage() {
4319
4348
  return typeof localStorage === "undefined" ? null : localStorage;
4320
4349
  }
4350
+ function windowStorage() {
4351
+ return typeof sessionStorage === "undefined" ? null : sessionStorage;
4352
+ }
4353
+ function readRecord(store, key) {
4354
+ try {
4355
+ return revive(JSON.parse(store?.getItem(key) ?? "null")) ?? void 0;
4356
+ } catch {
4357
+ return;
4358
+ }
4359
+ }
4321
4360
  let version = 0;
4322
4361
  const listeners$1 = /* @__PURE__ */ new Set();
4323
4362
  function notify() {
@@ -4339,32 +4378,31 @@ ${body}
4339
4378
  notify();
4340
4379
  }
4341
4380
  function readSideChatMeta(sessionId, tabId) {
4342
- const store = storage();
4343
- if (store === null) return void 0;
4344
- try {
4345
- const raw = store.getItem(keyOf(sessionId, tabId));
4346
- if (raw === null) return void 0;
4347
- return revive(JSON.parse(raw)) ?? void 0;
4348
- } catch {
4349
- return;
4350
- }
4381
+ const key = keyOf(sessionId, tabId);
4382
+ const record = readRecord(windowStorage(), key) ?? readRecord(storage(), key);
4383
+ return record?.runId === SIDENOTE_RUN_ID ? record : void 0;
4351
4384
  }
4352
4385
  /** 写/合并一个 tab 的 meta(整体替换语义由调用方组合:先读再写)。 */
4353
4386
  function writeSideChatMeta(record, options) {
4354
- const store = storage();
4355
- if (store === null) return;
4356
- try {
4357
- store.setItem(keyOf(record.sessionId, record.tabId), JSON.stringify(record));
4358
- if (options?.silent !== true) notify();
4387
+ const value = JSON.stringify({
4388
+ ...record,
4389
+ runId: record.runId ?? SIDENOTE_RUN_ID
4390
+ });
4391
+ for (const store of [windowStorage(), storage()]) try {
4392
+ store?.setItem(keyOf(record.sessionId, record.tabId), value);
4359
4393
  } catch {}
4394
+ if (options?.silent !== true) notify();
4360
4395
  }
4361
4396
  function dropSideChatMeta(sessionId, tabId) {
4362
- const store = storage();
4363
- if (store === null) return;
4397
+ const key = keyOf(sessionId, tabId);
4364
4398
  try {
4365
- store.removeItem(keyOf(sessionId, tabId));
4366
- notify();
4399
+ windowStorage()?.removeItem(key);
4400
+ } catch {}
4401
+ try {
4402
+ const store = storage();
4403
+ if (readRecord(store, key)?.runId === SIDENOTE_RUN_ID) store?.removeItem(key);
4367
4404
  } catch {}
4405
+ notify();
4368
4406
  }
4369
4407
  /** 枚举一个主会话的全部 meta 记录(按创建序升序)。 */
4370
4408
  function sideChatMetasOf(sessionId) {
@@ -4372,20 +4410,20 @@ ${body}
4372
4410
  }
4373
4411
  /** 枚举全部存活记录(跨会话;完成通知等全局监听用)。 */
4374
4412
  function sideChatMetasAll() {
4375
- const store = storage();
4376
- if (store === null) return [];
4377
- try {
4378
- const out = [];
4379
- for (let i = 0; i < store.length; i += 1) {
4413
+ return storedSideChatMetas().filter((record) => record.runId === SIDENOTE_RUN_ID);
4414
+ }
4415
+ /** Durable recovery records; a window's own copy wins over another window. */
4416
+ function storedSideChatMetas() {
4417
+ const records = /* @__PURE__ */ new Map();
4418
+ for (const store of [storage(), windowStorage()]) try {
4419
+ for (let i = 0; store && i < store.length; i++) {
4380
4420
  const key = store.key(i);
4381
- if (typeof key !== "string" || !key.startsWith(KEY_PREFIX$1)) continue;
4382
- const record = revive(JSON.parse(store.getItem(key) ?? "null"));
4383
- if (record !== null) out.push(record);
4421
+ if (!key?.startsWith(KEY_PREFIX$1)) continue;
4422
+ const record = readRecord(store, key);
4423
+ if (record) records.set(key, record);
4384
4424
  }
4385
- return out;
4386
- } catch {
4387
- return [];
4388
- }
4425
+ } catch {}
4426
+ return [...records.values()];
4389
4427
  }
4390
4428
  /**
4391
4429
  * 下一个实例编号:当前会话存活记录的最大编号 + 1。
@@ -4397,30 +4435,12 @@ ${body}
4397
4435
  return existing.length === 0 ? 1 : Math.max(...existing.map((r) => r.number)) + 1;
4398
4436
  }
4399
4437
  /**
4400
- * 孤键清扫:删掉 runId 与本页面生命周期不匹配的记录(上一页面周期的
4401
- * 残留)。插件 apply 时调用一次;撤掉活身份,等待实际恢复记录认领。
4402
- * 返回清扫数。
4438
+ * 启动时收集恢复候选;持久记录不作为存活证明,也不跨窗口删除。
4403
4439
  */
4404
4440
  function sweepOrphanedSideChatMeta() {
4405
- const store = storage();
4406
- if (store === null) return 0;
4407
- try {
4408
- const staleKeys = [];
4409
- for (let i = 0; i < store.length; i += 1) {
4410
- const key = store.key(i);
4411
- if (typeof key !== "string" || !key.startsWith(KEY_PREFIX$1)) continue;
4412
- const record = revive(JSON.parse(store.getItem(key) ?? "null"));
4413
- if (record === null || record.runId !== SIDENOTE_RUN_ID) {
4414
- if (record !== null) restorationCandidates.set(key, record);
4415
- staleKeys.push(key);
4416
- }
4417
- }
4418
- for (const key of staleKeys) store.removeItem(key);
4419
- if (staleKeys.length > 0) notify();
4420
- return staleKeys.length;
4421
- } catch {
4422
- return 0;
4423
- }
4441
+ restorationCandidates.clear();
4442
+ for (const record of storedSideChatMetas()) if (record.runId !== SIDENOTE_RUN_ID) restorationCandidates.set(keyOf(record.sessionId, record.tabId), record);
4443
+ return restorationCandidates.size;
4424
4444
  }
4425
4445
  //#endregion
4426
4446
  //#region src/client/sidechat/identity.ts
@@ -5279,10 +5299,19 @@ ${body}
5279
5299
  }, [session]);
5280
5300
  const context = useMainContext(ctx, meta.parentSessionId ?? scope.sessionId, childId, session, meta.snapshotOnly === true, meta.forkedMidTurn === true);
5281
5301
  const { boundarySeq, messages, history, progress, loadContext, loadImage, childChat: chat } = context;
5302
+ const [boundaryError, setBoundaryError] = (0, react.useState)();
5282
5303
  (0, react.useEffect)(() => {
5283
- if (session && !meta.snapshotOnly && boundarySeq === void 0) loadForkBoundary(session).catch((error) => {
5284
- console.warn("[dsh-sidenote] 恢复继承边界失败:", error);
5304
+ if (!session || meta.snapshotOnly || boundarySeq !== void 0) {
5305
+ setBoundaryError(void 0);
5306
+ return;
5307
+ }
5308
+ let active = true;
5309
+ loadForkBoundary(session).catch((error) => {
5310
+ if (active) setBoundaryError(String(error instanceof Error ? error.message : error));
5285
5311
  });
5312
+ return () => {
5313
+ active = false;
5314
+ };
5286
5315
  }, [
5287
5316
  session,
5288
5317
  meta.snapshotOnly,
@@ -5327,7 +5356,7 @@ ${body}
5327
5356
  const composer = useComposer(ctx, session, childId);
5328
5357
  (0, react.useEffect)(() => childId === void 0 ? void 0 : registerSubmissionExtras(childId, extras), [childId, extras]);
5329
5358
  (0, react.useEffect)(() => {
5330
- if (meta.topic !== void 0) return;
5359
+ if (meta.topic !== void 0 || boundarySeq === void 0) return;
5331
5360
  const first = messages.find((message) => message.role === "user" && (message.seq ?? -1) > (boundarySeq ?? -1));
5332
5361
  if (first) extras.onSubmitText(first.text);
5333
5362
  }, [
@@ -5480,6 +5509,7 @@ ${body}
5480
5509
  loadImage,
5481
5510
  progress,
5482
5511
  loadContext,
5512
+ loadError: boundaryError,
5483
5513
  loadProgressImage: context.loadProgressImage,
5484
5514
  contextPending: context.contextPending
5485
5515
  };
@@ -5510,6 +5540,7 @@ ${body}
5510
5540
  type: "button",
5511
5541
  className: sidechat_module_css_default.nativeAction,
5512
5542
  disabled: running || replies.length === 0 || allAdded,
5543
+ title: t(boundarySeq === void 0 ? "reflowPending" : "reflowAllTitle"),
5513
5544
  "aria-pressed": allAdded,
5514
5545
  onClick: () => {
5515
5546
  for (const reply of replies) addReply(side, reply, questions.get(reply.key));
@@ -5684,6 +5715,11 @@ ${body}
5684
5715
  title: titleOf(sessionId, nativeTab.id),
5685
5716
  ...meta === void 0 ? {} : { meta }
5686
5717
  };
5718
+ if (isRestoredNativeTab(nativeTab) && !meta.childId) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5719
+ role: "alert",
5720
+ className: sidechat_module_css_default.errorRow,
5721
+ children: t("sideRestoreMissing")
5722
+ });
5687
5723
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5688
5724
  className: sidechat_module_css_default.nativeHost,
5689
5725
  "data-dsh-sidenote-native-host": "",
@@ -6612,34 +6648,34 @@ ${body}
6612
6648
  document.head.appendChild(tag);
6613
6649
  }
6614
6650
  var annotate_module_css_default = {
6615
- "toolbarButton": "_SVViG_toolbarButton",
6616
- "badge": "_SVViG_badge",
6617
- "editorAskRow": "_SVViG_editorAskRow",
6618
- "editorNew": "_SVViG_editorNew",
6619
- "toolbar": "_SVViG_toolbar",
6620
- "editorTarget": "_SVViG_editorTarget",
6621
- "hint": "_SVViG_hint",
6622
- "sentCardQuote": "_SVViG_sentCardQuote",
6623
- "confirmButton": "_SVViG_confirmButton",
6624
- "saveButton": "_SVViG_saveButton",
6651
+ "toolbarBelow": "_SVViG_toolbarBelow",
6652
+ "editorSpacer": "_SVViG_editorSpacer",
6653
+ "badgeSent": "_SVViG_badgeSent",
6625
6654
  "sentChip": "_SVViG_sentChip",
6655
+ "sentCardTitle": "_SVViG_sentCardTitle",
6656
+ "sentCardQuote": "_SVViG_sentCardQuote",
6657
+ "hint": "_SVViG_hint",
6626
6658
  "dshSidenoteHintIn": "_SVViG_dshSidenoteHintIn",
6659
+ "editorAskRow": "_SVViG_editorAskRow",
6627
6660
  "hintClose": "_SVViG_hintClose",
6661
+ "toolbar": "_SVViG_toolbar",
6662
+ "badge": "_SVViG_badge",
6663
+ "deleteButton": "_SVViG_deleteButton",
6664
+ "sentChipRow": "_SVViG_sentChipRow",
6665
+ "saveButton": "_SVViG_saveButton",
6666
+ "sentCardNote": "_SVViG_sentCardNote",
6667
+ "toolbarButton": "_SVViG_toolbarButton",
6668
+ "editorNew": "_SVViG_editorNew",
6628
6669
  "editorEdit": "_SVViG_editorEdit",
6670
+ "editorTextarea": "_SVViG_editorTextarea",
6629
6671
  "editorFooter": "_SVViG_editorFooter",
6630
- "toolbarBelow": "_SVViG_toolbarBelow",
6631
6672
  "highlight": "_SVViG_highlight",
6632
- "editorTextarea": "_SVViG_editorTextarea",
6633
- "sentChipRow": "_SVViG_sentChipRow",
6634
- "cancelButton": "_SVViG_cancelButton",
6635
6673
  "editorAsk": "_SVViG_editorAsk",
6636
- "deleteButton": "_SVViG_deleteButton",
6637
6674
  "editorInput": "_SVViG_editorInput",
6638
- "editorError": "_SVViG_editorError",
6639
- "badgeSent": "_SVViG_badgeSent",
6640
- "editorSpacer": "_SVViG_editorSpacer",
6641
- "sentCardTitle": "_SVViG_sentCardTitle",
6642
- "sentCardNote": "_SVViG_sentCardNote"
6675
+ "confirmButton": "_SVViG_confirmButton",
6676
+ "editorTarget": "_SVViG_editorTarget",
6677
+ "cancelButton": "_SVViG_cancelButton",
6678
+ "editorError": "_SVViG_editorError"
6643
6679
  };
6644
6680
  //#endregion
6645
6681
  //#region src/client/annotate/hint.tsx
@@ -8182,7 +8218,7 @@ ${body}
8182
8218
  /** Run before orphan-meta cleanup so previous live/closed children can migrate. */
8183
8219
  function registerSideSessionVisibility(ctx) {
8184
8220
  const visibility = sessionVisibilityOf(ctx);
8185
- const ids = [...sideChatMetasAll(), ...allClosedSideChats()].flatMap((record) => record.childId ? [record.childId] : []);
8221
+ const ids = [...storedSideChatMetas(), ...allClosedSideChats()].flatMap((record) => record.childId ? [record.childId] : []);
8186
8222
  visibility.hide(...ids);
8187
8223
  ctx.effect(() => visibility.watch(), "dsh-sidenote: side session visibility");
8188
8224
  registerSessionListFilter(ctx, visibility);
@@ -3,6 +3,7 @@ export interface ContextEvent {
3
3
  type: string;
4
4
  seq: number;
5
5
  data: {
6
+ inherited?: boolean;
6
7
  turn?: number | null;
7
8
  commandId?: string;
8
9
  sourceCommandId?: string;
@@ -42,8 +43,18 @@ export declare function contextEventSource(session: SessionFace | undefined): Ob
42
43
  * its opening lock while it is unfinished; the first-send delta can then carry
43
44
  * a checkpoint that commits later. Automatic compaction stays in the busy turn. */
44
45
  export declare function unfinishedCompactionCut(session: SessionFace | undefined): number | undefined;
45
- /** Published event window, shared by all supported hosts. Its last seed marker
46
- * is the child’s own fork cut (earlier markers can belong to ancestor forks). */
46
+ /** Restore also appends end-seed, but only inherited=true records a fork cut.
47
+ * A saved copy resolves the cut before its original first-send snapshot. */
48
+ export declare function inheritedBoundaryOf(entries: readonly {
49
+ event: {
50
+ type: string;
51
+ seq: number;
52
+ data?: {
53
+ inherited?: boolean;
54
+ };
55
+ };
56
+ }[], beforeSeq?: number): number | undefined;
57
+ /** The loaded window can lack the inherited marker; undefined means pending. */
47
58
  export declare function inheritedBoundarySource(session: SessionFace | undefined): ObservableSnapshot<number | undefined> | undefined;
48
59
  /** Reuse the native contiguous window, but verify coverage: the host swallows
49
60
  * page failures and a resolved loadThrough alone does not establish success. */
@@ -209,6 +209,8 @@ export declare const en: {
209
209
  expandAll: string;
210
210
  jumpToLatest: string;
211
211
  reflowAll: string;
212
+ reflowPending: string;
213
+ sideRestoreMissing: string;
212
214
  reflowAllTitle: string;
213
215
  reflowAllDone: string;
214
216
  promote: string;
@@ -12,6 +12,7 @@ export interface InheritedContextData {
12
12
  takenAt?: string | null;
13
13
  };
14
14
  loadContext?: () => Promise<void>;
15
+ loadError?: string;
15
16
  }
16
17
  /** Side and saved sessions share the same reader and its local scroll owner. */
17
18
  export declare function InheritedContext({ context }: {
@@ -18,7 +18,7 @@ export interface ConversationRendererProps {
18
18
  export type RenderConversation = (reference: SessionReference, side: SideConversation) => ReactNode;
19
19
  export declare function renderSideConversation(props: ConversationRendererProps, reference: SessionReference, side: SideConversation): ReactNode;
20
20
  /** Only completed, fork-local final answers are eligible for reflow. */
21
- export declare function finalReplies(messages: readonly ChatMessage[], boundarySeq?: number, completed?: ReadonlySet<number>): ChatMessage[];
21
+ export declare function finalReplies(messages: readonly ChatMessage[], boundarySeq: number | undefined, completed?: ReadonlySet<number>): ChatMessage[];
22
22
  export declare function isReplyAdded(side: SideConversation, message: ChatMessage, question?: string): boolean;
23
23
  export declare function addReply(side: SideConversation, message: ChatMessage, question?: string): void;
24
24
  export declare function registerNativeConversation(ctx: Context): void;
@@ -1,4 +1,5 @@
1
1
  import { type SnapshotEntry } from './snapshot.ts';
2
+ import { inheritedBoundaryOf } from '../host/chat.ts';
2
3
  /** Read-only rows have event identities, independent of the native side flow. */
3
4
  export interface ContextEntry extends SnapshotEntry {
4
5
  key: string;
@@ -8,14 +9,9 @@ export interface ContextEntry extends SnapshotEntry {
8
9
  export declare function contextEntriesOf(nodes: readonly unknown[]): ContextEntry[];
9
10
  /** A committed checkpoint replaces the inherited prefix, not its surviving tail. */
10
11
  export declare function contextHistory(history: readonly ContextEntry[], progress: readonly SnapshotEntry[]): readonly ContextEntry[];
11
- /** Saving adds a new end-seed; the first-send cut must NOT move to that seed.
12
- * Older snapshots lack an explicit cut, so use the seed preceding their input. */
13
- export declare function savedContextOf(nodes: readonly unknown[], events: readonly {
14
- event: {
15
- type: string;
16
- seq: number;
17
- };
18
- }[], hasMore?: boolean): {
12
+ /** Saving preserves the latest side chat's reading cut. An active side chat
13
+ * freezes its first own snapshot after afterSeq. Restore markers never establish ownership. */
14
+ export declare function savedContextOf(nodes: readonly unknown[], events: Parameters<typeof inheritedBoundaryOf>[0], hasMore?: boolean, afterSeq?: number): {
19
15
  boundarySeq: number;
20
16
  content: readonly {
21
17
  type?: string;
@@ -14,11 +14,9 @@
14
14
  * 孤键清扫:不能用旧页面键判断 tab 仍存活,而
15
15
  * 「有记录 ⇒ tab 存活」是编排层的判定——残留记录会把入口折叠成聚焦一个
16
16
  * 不存在的 tab(死锁,对抗性审查 B2)。每条记录带本页面生命周期的
17
- * runId,插件 apply 时撤掉 runId 不匹配的活记录。仅真正挂载的 restored
18
- * tab(navigation revision 0)可重新绑定恢复候选;普通新开不能认领它。
19
- * 已知限制:同一 origin 开两个窗口时,后启动窗口的清扫会清掉先开窗口的
20
- * 活记录(其面板 reconcile 幂等重建,childId 丢失退回首开 fork)——
21
- * 多窗口为边角场景,记录在案。
17
+ * runId,只有本页面认领的记录才算存活。sessionStorage 保留本窗口身份,
18
+ * localStorage 提供跨窗口关闭后的恢复副本;启动时只收集恢复候选,
19
+ * 不删除其他窗口的记录。仅 restored tab(revision 0)可认领旧身份。
22
20
  */
23
21
  export interface SideChatMetaRecord {
24
22
  readonly tabId: string;
@@ -68,6 +66,8 @@ export declare function dropSideChatMeta(sessionId: string, tabId: string): void
68
66
  export declare function sideChatMetasOf(sessionId: string): SideChatMetaRecord[];
69
67
  /** 枚举全部存活记录(跨会话;完成通知等全局监听用)。 */
70
68
  export declare function sideChatMetasAll(): SideChatMetaRecord[];
69
+ /** Durable recovery records; a window's own copy wins over another window. */
70
+ export declare function storedSideChatMetas(): SideChatMetaRecord[];
71
71
  /**
72
72
  * 下一个实例编号:当前会话存活记录的最大编号 + 1。
73
73
  * 单实例期调用方保证同会话同时至多一条存活记录(held 规则),
@@ -75,8 +75,6 @@ export declare function sideChatMetasAll(): SideChatMetaRecord[];
75
75
  */
76
76
  export declare function nextSideChatNumber(sessionId: string): number;
77
77
  /**
78
- * 孤键清扫:删掉 runId 与本页面生命周期不匹配的记录(上一页面周期的
79
- * 残留)。插件 apply 时调用一次;撤掉活身份,等待实际恢复记录认领。
80
- * 返回清扫数。
78
+ * 启动时收集恢复候选;持久记录不作为存活证明,也不跨窗口删除。
81
79
  */
82
80
  export declare function sweepOrphanedSideChatMeta(): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidenote",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Follow the tangent. Keep the thread. Annotate, explore on the side, and bring answers back to DeepSeek Harness. / 思路可以分岔,主线始终向前。划选注释、侧边追问,再把结论带回正在推进的任务。",
5
5
  "icon": "docs/assets/logo.svg",
6
6
  "type": "module",
@@ -3,7 +3,7 @@ import type { Context, ConversationSnapshot, ObservableSnapshot, SessionBinding,
3
3
  export interface ContextEvent {
4
4
  type: string
5
5
  seq: number
6
- data: { turn?: number | null; commandId?: string; sourceCommandId?: string; source?: { kind?: string }; content?: readonly { type: string; text?: string }[]; message?: { content: readonly { type: string; text?: string }[] }; summary?: readonly { type: string; text?: string }[]; shadowedSeqs?: readonly number[] }
6
+ data: { inherited?: boolean; turn?: number | null; commandId?: string; sourceCommandId?: string; source?: { kind?: string }; content?: readonly { type: string; text?: string }[]; message?: { content: readonly { type: string; text?: string }[] }; summary?: readonly { type: string; text?: string }[]; shadowedSeqs?: readonly number[] }
7
7
  surfaceOp?: 'append' | { op: 'replace'; startSeq: number; endSeq: number }
8
8
  }
9
9
  export interface ContextEventWindow { entries: readonly { event: ContextEvent }[]; hasMore?: boolean }
@@ -24,16 +24,19 @@ export function unfinishedCompactionCut(session: SessionFace | undefined): numbe
24
24
  return (command?.seq ?? last.seq) - 1
25
25
  }
26
26
 
27
- /** Published event window, shared by all supported hosts. Its last seed marker
28
- * is the child’s own fork cut (earlier markers can belong to ancestor forks). */
27
+ /** Restore also appends end-seed, but only inherited=true records a fork cut.
28
+ * A saved copy resolves the cut before its original first-send snapshot. */
29
+ export function inheritedBoundaryOf(entries: readonly { event: { type: string; seq: number; data?: { inherited?: boolean } } }[], beforeSeq = Infinity): number | undefined {
30
+ const seed = entries.findLast(({ event }) => event.type === 'session/end-seed' && event.data?.inherited === true && event.seq < beforeSeq)
31
+ return seed === undefined ? undefined : seed.event.seq - 1
32
+ }
33
+
34
+ /** The loaded window can lack the inherited marker; undefined means pending. */
29
35
  export function inheritedBoundarySource(session: SessionFace | undefined): ObservableSnapshot<number | undefined> | undefined {
30
36
  const source = contextEventSource(session)
31
37
  return source && {
32
38
  subscribe: notify => source.subscribe(notify),
33
- getSnapshot: () => {
34
- const seed = source.getSnapshot().entries.findLast(entry => entry.event.type === 'session/end-seed')
35
- return seed === undefined ? undefined : seed.event.seq - 1
36
- },
39
+ getSnapshot: () => inheritedBoundaryOf(source.getSnapshot().entries),
37
40
  }
38
41
  }
39
42
 
@@ -229,6 +229,8 @@ export const en = {
229
229
  expandAll: 'Expand all',
230
230
  jumpToLatest: '↓ Jump to latest',
231
231
  reflowAll: 'Reflow all',
232
+ reflowPending: 'Confirming this side chat’s origin before bringing replies back',
233
+ sideRestoreMissing: 'The original side chat could not be restored. Reopen it from the recently closed list in /side.',
232
234
  reflowAllTitle: 'Bring all Q&A pairs back to the main session',
233
235
  reflowAllDone: 'Reflowed ✓',
234
236
  promote: 'Save as session',
@@ -464,6 +466,8 @@ export const zh: Record<CopyKey, string> = {
464
466
  expandAll: '全部展开',
465
467
  jumpToLatest: '↓ 跳到最新',
466
468
  reflowAll: '整段回流',
469
+ reflowPending: '正在确认侧聊来源,确认后可回流',
470
+ sideRestoreMissing: '未能恢复原侧聊。请从 /side 的最近关闭列表重新打开。',
467
471
  reflowAllTitle: '把该侧聊全部问答对带回主会话(chip 可预览/逐条撤)',
468
472
  reflowAllDone: '已回流 ✓',
469
473
  promote: '保存为正式会话',
@@ -16,6 +16,7 @@ export interface InheritedContextData {
16
16
  loadProgressImage?: (attachment: unknown) => Promise<string>
17
17
  progress?: { entries: SnapshotEntry[]; takenAt?: string | null }
18
18
  loadContext?: () => Promise<void>
19
+ loadError?: string
19
20
  }
20
21
 
21
22
  /** Side and saved sessions share the same reader and its local scroll owner. */
@@ -42,7 +43,8 @@ export function InheritedContext({ context }: { context: InheritedContextData })
42
43
  <IconChevronDownOutline14 size={12} className={expanded ? css.contextChevronOpen : css.contextChevron} />
43
44
  <span>{t('mainContext')}</span><span className={css.contextHint}>{context.contextPending ? t('contextAtSend') : context.progress?.takenAt ? t('contextAsOf', { time: context.progress.takenAt }) : t('contextInherited')}</span>
44
45
  </button>
45
- {expanded && <ContextProgress id={contextId} history={context.history} entries={context.progress?.entries ?? []} live={context.contextPending === true} loading={loading} error={error} onRetry={() => { setAttempt(value => value + 1) }} loadImage={context.loadImage} loadProgressImage={context.loadProgressImage} />}
46
+ {!expanded && context.loadError && <div className={css.errorRow} role="alert">{context.loadError}<button type="button" className={css.nativeAction} onClick={() => { fold.toggle('inherited') }}>{t('contextRetry')}</button></div>}
47
+ {expanded && <ContextProgress id={contextId} history={context.history} entries={context.progress?.entries ?? []} live={context.contextPending === true} loading={loading} error={error ?? (loading ? undefined : context.loadError)} onRetry={() => { setAttempt(value => value + 1) }} loadImage={context.loadImage} loadProgressImage={context.loadProgressImage} />}
46
48
  </div>
47
49
  </div>
48
50
  }
@@ -41,7 +41,8 @@ function Content(props: ContentProps) {
41
41
  }
42
42
 
43
43
  /** Only completed, fork-local final answers are eligible for reflow. */
44
- export function finalReplies(messages: readonly ChatMessage[], boundarySeq = -1, completed?: ReadonlySet<number>): ChatMessage[] {
44
+ export function finalReplies(messages: readonly ChatMessage[], boundarySeq: number | undefined, completed?: ReadonlySet<number>): ChatMessage[] {
45
+ if (boundarySeq === undefined) return []
45
46
  const replies: ChatMessage[] = []
46
47
  let final: ChatMessage | undefined
47
48
  const finish = (): void => { if (final && !final.streaming && !final.interrupted && final.text.trim() !== '' && (completed === undefined || (final.seq !== undefined && completed.has(final.seq)))) replies.push(final) }
@@ -59,6 +60,7 @@ export function isReplyAdded(side: SideConversation, message: ChatMessage, quest
59
60
  return side.reflow.list(side.parentSessionId).some(item => item.sourceKey === key || (item.sourceKey === undefined && item.sideTitle === side.title && item.text === message.text && item.question === question))
60
61
  }
61
62
  export function addReply(side: SideConversation, message: ChatMessage, question?: string): void {
63
+ if (side.boundarySeq === undefined || message.seq === undefined || message.seq <= side.boundarySeq) return
62
64
  if (!isReplyAdded(side, message, question)) {
63
65
  side.reflow.add(side.parentSessionId, side.title, message.text, question, `${side.sessionId}:${message.seq ?? message.key}`)
64
66
  }
@@ -77,9 +77,17 @@ export function SavedConversation({ ctx, Original, props }: { ctx: Context; Orig
77
77
  useEffect(() => {
78
78
  if (typeof markerSeq !== 'number' || saved) return
79
79
  let active = true
80
- void loadInheritedContext(session, markerSeq).catch(reason => { if (active) setError(String(reason instanceof Error ? reason.message : reason)) })
80
+ void loadInheritedContext(session, markerSeq).then(async () => {
81
+ // Legacy snapshots omit the cut. Their inherited marker can be on an
82
+ // earlier page than the indexed input; resolve it before classifying rows.
83
+ const window = source?.getSnapshot()
84
+ const nodes = chat?.getLegacy()?.nodes ?? []
85
+ const marker = nodes.find(value => (value as { seq: number }).seq === markerSeq) as { content?: readonly { type: string; text?: string }[] } | undefined
86
+ const prefix = splitSnapshotPrefix(marker?.content?.find(block => block.type === 'text')?.text ?? '')
87
+ if (prefix && prefix.boundarySeq === undefined && !savedContextOf(nodes, window?.entries ?? [], window?.hasMore) && window?.hasMore) await loadInheritedContext(session, 0)
88
+ }).catch(reason => { if (active) setError(String(reason instanceof Error ? reason.message : reason)) })
81
89
  return () => { active = false }
82
- }, [markerSeq, !!saved, session])
90
+ }, [markerSeq, !!saved, session, source, chat])
83
91
  const progress = useMemo(() => saved ? { entries: snapshotEntriesFromText(saved.snapshot, saved.content), takenAt: saved.takenAt } : undefined, [saved])
84
92
  const history = useMemo(() => contextHistory(contextEntriesOf(effectiveContextNodes(legacy?.nodes ?? [], window, saved?.boundarySeq ?? -1).nodes), progress?.entries ?? []), [legacy, window, saved, progress])
85
93
  const loadImage = useMemo(() => contextImageLoader(ctx, props.sessionId), [ctx, props.sessionId])
@@ -57,8 +57,12 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore;
57
57
  useEffect(() => { openSessionWindow(session) }, [session])
58
58
  const context = useMainContext(ctx, meta.parentSessionId ?? scope.sessionId, childId, session, meta.snapshotOnly === true, meta.forkedMidTurn === true)
59
59
  const { boundarySeq, messages, history, progress, loadContext, loadImage, childChat: chat } = context
60
+ const [boundaryError, setBoundaryError] = useState<string>()
60
61
  useEffect(() => {
61
- if (session && !meta.snapshotOnly && boundarySeq === undefined) void loadForkBoundary(session).catch(error => { console.warn('[dsh-sidenote] 恢复继承边界失败:', error) })
62
+ if (!session || meta.snapshotOnly || boundarySeq !== undefined) { setBoundaryError(undefined); return }
63
+ let active = true
64
+ void loadForkBoundary(session).catch(error => { if (active) setBoundaryError(String(error instanceof Error ? error.message : error)) })
65
+ return () => { active = false }
62
66
  }, [session, meta.snapshotOnly, boundarySeq])
63
67
  // Repair pre-fix tab metadata from the durable fork marker, without touching history.
64
68
  useEffect(() => {
@@ -84,7 +88,7 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore;
84
88
  const composer = useComposer(ctx, session, childId)
85
89
  useEffect(() => childId === undefined ? undefined : registerSubmissionExtras(childId, extras), [childId, extras])
86
90
  useEffect(() => {
87
- if (meta.topic !== undefined) return
91
+ if (meta.topic !== undefined || boundarySeq === undefined) return
88
92
  const first = messages.find(message => message.role === 'user' && (message.seq ?? -1) > (boundarySeq ?? -1))
89
93
  if (first) extras.onSubmitText(first.text)
90
94
  }, [messages, meta.topic, boundarySeq, extras])
@@ -145,7 +149,7 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore;
145
149
  if (phase === 'fork-error') return <StateScreen title={t('forkErrorTitle')} detail={forkError ?? undefined} hint={t('forkErrorHint')} />
146
150
  if (phase === 'missing') return <StateScreen title={t('missingTitle')} detail={t('missingDetail')} hint={t('missingHint')} />
147
151
  if (phase !== 'chat' || !reference || !childId) return <StateScreen title={t('preparing')} />
148
- const side: SideConversation = { sessionId: childId, parentSessionId: meta.parentSessionId ?? scope.sessionId, title: tab.title, boundarySeq, reflow: props.reflow, history, loadImage, progress, loadContext, loadProgressImage: context.loadProgressImage, contextPending: context.contextPending }
152
+ const side: SideConversation = { sessionId: childId, parentSessionId: meta.parentSessionId ?? scope.sessionId, title: tab.title, boundarySeq, reflow: props.reflow, history, loadImage, progress, loadContext, loadError: boundaryError, loadProgressImage: context.loadProgressImage, contextPending: context.contextPending }
149
153
  const replies = finalReplies(messages, boundarySeq, new Set(chat?.getCompletedReplySeqs?.() ?? []))
150
154
  const questions = pairQuestions(messages)
151
155
  const allAdded = replies.length > 0 && replies.every(reply => isReplyAdded(side, reply, questions.get(reply.key)))
@@ -155,7 +159,7 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore;
155
159
  <div className={css.nativeToolbar}>
156
160
  <button type="button" className={css.nativeAction} onClick={() => { openSession(ctx, side.parentSessionId) }}>{t(parentPending ? 'parentPending' : parentSnapshot?.running ? 'parentRunning' : 'parentIdle')}</button>
157
161
  <span className={css.nativeToolbarActions}>
158
- <button type="button" className={css.nativeAction} disabled={running || replies.length === 0 || allAdded} aria-pressed={allAdded} onClick={() => { for (const reply of replies) addReply(side, reply, questions.get(reply.key)) }}>{t(allAdded ? 'reflowAllDone' : 'reflowAll')}</button>
162
+ <button type="button" className={css.nativeAction} disabled={running || replies.length === 0 || allAdded} title={t(boundarySeq === undefined ? 'reflowPending' : 'reflowAllTitle')} aria-pressed={allAdded} onClick={() => { for (const reply of replies) addReply(side, reply, questions.get(reply.key)) }}>{t(allAdded ? 'reflowAllDone' : 'reflowAll')}</button>
159
163
  <button type="button" className={css.nativeAction} disabled={running || composer.pending || hasDraft || saving} title={t(running || composer.pending ? 'promoteBusy' : hasDraft ? 'promoteDraftBusy' : 'promoteTitle')} onClick={() => { void promote() }}>{t('promote')}</button>
160
164
  </span>
161
165
  </div>
@@ -1,4 +1,5 @@
1
1
  import { snapshotEntriesOf, splitSnapshotPrefix, type SnapshotEntry } from './snapshot.ts'
2
+ import { inheritedBoundaryOf } from '../host/chat.ts'
2
3
 
3
4
  /** Read-only rows have event identities, independent of the native side flow. */
4
5
  export interface ContextEntry extends SnapshotEntry {
@@ -38,16 +39,19 @@ export function contextHistory(history: readonly ContextEntry[], progress: reado
38
39
  return progress.some(entry => entry.kind === 'checkpoint') ? [] : history
39
40
  }
40
41
 
41
- /** Saving adds a new end-seed; the first-send cut must NOT move to that seed.
42
- * Older snapshots lack an explicit cut, so use the seed preceding their input. */
43
- export function savedContextOf(nodes: readonly unknown[], events: readonly { event: { type: string; seq: number } }[], hasMore = false) {
44
- for (const value of [...nodes].reverse()) {
42
+ /** Saving preserves the latest side chat's reading cut. An active side chat
43
+ * freezes its first own snapshot after afterSeq. Restore markers never establish ownership. */
44
+ export function savedContextOf(nodes: readonly unknown[], events: Parameters<typeof inheritedBoundaryOf>[0], hasMore = false, afterSeq?: number) {
45
+ for (const value of afterSeq === undefined ? [...nodes].reverse() : nodes) {
45
46
  const node = value as { kind: string; seq: number; content?: readonly { type?: string; text?: string; attachment?: unknown }[] }
46
- if (node.kind !== 'user' && node.kind !== 'steering') continue
47
+ if (node.seq <= (afterSeq ?? -1) || node.kind !== 'user' && node.kind !== 'steering') continue
47
48
  const saved = splitSnapshotPrefix(node.content?.find(block => block.type === 'text')?.text ?? '')
48
49
  if (!saved) continue
49
- if (saved.boundarySeq === undefined && hasMore && !events.some(({ event }) => event.type === 'session/end-seed' && event.seq < node.seq)) continue
50
- const boundarySeq = saved.boundarySeq ?? ((events.findLast(({ event }) => event.type === 'session/end-seed' && event.seq < node.seq)?.event.seq ?? 0) - 1)
50
+ let boundarySeq = saved.boundarySeq ?? inheritedBoundaryOf(events, node.seq)
51
+ if (boundarySeq === undefined) {
52
+ if (hasMore || events.some(({ event }) => event.type === 'session/end-seed' && event.seq < node.seq)) continue
53
+ boundarySeq = -1
54
+ }
51
55
  if (!Number.isSafeInteger(boundarySeq) || boundarySeq < -1 || boundarySeq >= node.seq) continue
52
56
  return { ...saved, boundarySeq, content: node.content }
53
57
  }
@@ -14,11 +14,9 @@
14
14
  * 孤键清扫:不能用旧页面键判断 tab 仍存活,而
15
15
  * 「有记录 ⇒ tab 存活」是编排层的判定——残留记录会把入口折叠成聚焦一个
16
16
  * 不存在的 tab(死锁,对抗性审查 B2)。每条记录带本页面生命周期的
17
- * runId,插件 apply 时撤掉 runId 不匹配的活记录。仅真正挂载的 restored
18
- * tab(navigation revision 0)可重新绑定恢复候选;普通新开不能认领它。
19
- * 已知限制:同一 origin 开两个窗口时,后启动窗口的清扫会清掉先开窗口的
20
- * 活记录(其面板 reconcile 幂等重建,childId 丢失退回首开 fork)——
21
- * 多窗口为边角场景,记录在案。
17
+ * runId,只有本页面认领的记录才算存活。sessionStorage 保留本窗口身份,
18
+ * localStorage 提供跨窗口关闭后的恢复副本;启动时只收集恢复候选,
19
+ * 不删除其他窗口的记录。仅 restored tab(revision 0)可认领旧身份。
22
20
  */
23
21
  export interface SideChatMetaRecord {
24
22
  readonly tabId: string
@@ -105,6 +103,15 @@ function storage(): Storage | null {
105
103
  return typeof localStorage === 'undefined' ? null : localStorage
106
104
  }
107
105
 
106
+ function windowStorage(): Storage | null {
107
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage
108
+ }
109
+
110
+ function readRecord(store: Storage | null, key: string): SideChatMetaRecord | undefined {
111
+ try { return revive(JSON.parse(store?.getItem(key) ?? 'null')) ?? undefined }
112
+ catch { return undefined }
113
+ }
114
+
108
115
  // ── 变更订阅(标题槽活渲染用;版本号单调递增即可,不区分键) ──────────────────
109
116
 
110
117
  let version = 0
@@ -130,34 +137,29 @@ export function notifySideChatMetaListeners(): void {
130
137
  }
131
138
 
132
139
  export function readSideChatMeta(sessionId: string, tabId: string): SideChatMetaRecord | undefined {
133
- const store = storage()
134
- if (store === null) return undefined
135
- try {
136
- const raw = store.getItem(keyOf(sessionId, tabId))
137
- if (raw === null) return undefined
138
- return revive(JSON.parse(raw)) ?? undefined
139
- } catch {
140
- return undefined
141
- }
140
+ const key = keyOf(sessionId, tabId)
141
+ const record = readRecord(windowStorage(), key) ?? readRecord(storage(), key)
142
+ return record?.runId === SIDENOTE_RUN_ID ? record : undefined
142
143
  }
143
144
 
144
145
  /** 写/合并一个 tab 的 meta(整体替换语义由调用方组合:先读再写)。 */
145
146
  export function writeSideChatMeta(record: SideChatMetaRecord, options?: { silent?: boolean }): void {
146
- const store = storage()
147
- if (store === null) return
148
- try {
149
- store.setItem(keyOf(record.sessionId, record.tabId), JSON.stringify(record))
150
- if (options?.silent !== true) notify()
151
- } catch { /* 隐私模式/写失败:meta 缺席降级为无绑定首开 */ }
147
+ const value = JSON.stringify({ ...record, runId: record.runId ?? SIDENOTE_RUN_ID })
148
+ for (const store of [windowStorage(), storage()]) {
149
+ try { store?.setItem(keyOf(record.sessionId, record.tabId), value) }
150
+ catch { /* The other store can still preserve this identity. */ }
151
+ }
152
+ if (options?.silent !== true) notify()
152
153
  }
153
154
 
154
155
  export function dropSideChatMeta(sessionId: string, tabId: string): void {
155
- const store = storage()
156
- if (store === null) return
156
+ const key = keyOf(sessionId, tabId)
157
+ try { windowStorage()?.removeItem(key) } catch { /* ignore */ }
157
158
  try {
158
- store.removeItem(keyOf(sessionId, tabId))
159
- notify()
159
+ const store = storage()
160
+ if (readRecord(store, key)?.runId === SIDENOTE_RUN_ID) store?.removeItem(key)
160
161
  } catch { /* ignore */ }
162
+ notify()
161
163
  }
162
164
 
163
165
  /** 枚举一个主会话的全部 meta 记录(按创建序升序)。 */
@@ -167,20 +169,23 @@ export function sideChatMetasOf(sessionId: string): SideChatMetaRecord[] {
167
169
 
168
170
  /** 枚举全部存活记录(跨会话;完成通知等全局监听用)。 */
169
171
  export function sideChatMetasAll(): SideChatMetaRecord[] {
170
- const store = storage()
171
- if (store === null) return []
172
- try {
173
- const out: SideChatMetaRecord[] = []
174
- for (let i = 0; i < store.length; i += 1) {
175
- const key = store.key(i)
176
- if (typeof key !== 'string' || !key.startsWith(KEY_PREFIX)) continue
177
- const record = revive(JSON.parse(store.getItem(key) ?? 'null'))
178
- if (record !== null) out.push(record)
179
- }
180
- return out
181
- } catch {
182
- return []
172
+ return storedSideChatMetas().filter(record => record.runId === SIDENOTE_RUN_ID)
173
+ }
174
+
175
+ /** Durable recovery records; a window's own copy wins over another window. */
176
+ export function storedSideChatMetas(): SideChatMetaRecord[] {
177
+ const records = new Map<string, SideChatMetaRecord>()
178
+ for (const store of [storage(), windowStorage()]) {
179
+ try {
180
+ for (let i = 0; store && i < store.length; i++) {
181
+ const key = store.key(i)
182
+ if (!key?.startsWith(KEY_PREFIX)) continue
183
+ const record = readRecord(store, key)
184
+ if (record) records.set(key, record)
185
+ }
186
+ } catch { /* Read the other store when one is unavailable. */ }
183
187
  }
188
+ return [...records.values()]
184
189
  }
185
190
 
186
191
  /**
@@ -194,28 +199,12 @@ export function nextSideChatNumber(sessionId: string): number {
194
199
  }
195
200
 
196
201
  /**
197
- * 孤键清扫:删掉 runId 与本页面生命周期不匹配的记录(上一页面周期的
198
- * 残留)。插件 apply 时调用一次;撤掉活身份,等待实际恢复记录认领。
199
- * 返回清扫数。
202
+ * 启动时收集恢复候选;持久记录不作为存活证明,也不跨窗口删除。
200
203
  */
201
204
  export function sweepOrphanedSideChatMeta(): number {
202
- const store = storage()
203
- if (store === null) return 0
204
- try {
205
- const staleKeys: string[] = []
206
- for (let i = 0; i < store.length; i += 1) {
207
- const key = store.key(i)
208
- if (typeof key !== 'string' || !key.startsWith(KEY_PREFIX)) continue
209
- const record = revive(JSON.parse(store.getItem(key) ?? 'null'))
210
- if (record === null || record.runId !== SIDENOTE_RUN_ID) {
211
- if (record !== null) restorationCandidates.set(key, record)
212
- staleKeys.push(key)
213
- }
214
- }
215
- for (const key of staleKeys) store.removeItem(key)
216
- if (staleKeys.length > 0) notify()
217
- return staleKeys.length
218
- } catch {
219
- return 0
205
+ restorationCandidates.clear()
206
+ for (const record of storedSideChatMetas()) {
207
+ if (record.runId !== SIDENOTE_RUN_ID) restorationCandidates.set(keyOf(record.sessionId, record.tabId), record)
220
208
  }
209
+ return restorationCandidates.size
221
210
  }
@@ -199,6 +199,8 @@ function NativeSideChatBodyInner(props: NativeBodyInjected & NativeTabFrameworkP
199
199
  ...(meta === undefined ? {} : { meta }),
200
200
  }
201
201
 
202
+ if (isRestoredNativeTab(nativeTab) && !meta.childId) return <div role="alert" className={css.errorRow}>{t('sideRestoreMissing')}</div>
203
+
202
204
  return (
203
205
  <div className={css.nativeHost} data-dsh-sidenote-native-host="">
204
206
  <SideChatPanel ctx={ctx} store={undefined} scope={scope} tab={tab} visible={nativeTab.visible} reflow={reflow} annotations={annotations} renderConversation={(reference, side) => renderSideConversation(props, reference, side)} quoteRequestId={parseOpenParams(nativeTab.navigation.params)?.quoteRequestId} />
@@ -3,7 +3,7 @@ import type { Context, SessionFace } from '../host/contracts.ts'
3
3
  import { chatSourceOf, contextEventSource, inheritedBoundarySource, loadForkBoundary, loadInheritedContext } from '../host/chat.ts'
4
4
  import { contextImageLoader } from '../host/attachments.ts'
5
5
  import { transcriptOf } from '../chat/transcript.ts'
6
- import { contextEntriesOf, contextHistory } from './context.ts'
6
+ import { contextEntriesOf, contextHistory, savedContextOf } from './context.ts'
7
7
  import { snapshotImages, type ImageBlock } from '../../snapshotProtocol.ts'
8
8
  import { effectiveContextNodes, loadMainlineSnapshot, snapshotEntriesFromText, snapshotEntriesOf, splitSnapshotPrefix } from './snapshot.ts'
9
9
 
@@ -34,12 +34,7 @@ export function useMainContext(ctx: Context, parentSessionId: string, childId: s
34
34
  return { text, ...(attachments.length ? { images: { sessionId: parentSessionId, attachments } } : {}) }
35
35
  }, [ctx, parentSessionId, childId, session, snapshotOnly])
36
36
  const accept = useCallback(() => { if (sending.current) setAccepted(sending.current) }, [])
37
- const saved = useMemo(() => boundarySeq === undefined ? undefined : (legacy?.nodes ?? []).flatMap(value => {
38
- const node = value as { kind: string; seq: number; content?: readonly { type: string; text?: string; attachment?: unknown }[] }
39
- if (!['user', 'steering'].includes(node.kind) || node.seq <= boundarySeq) return []
40
- const prefix = splitSnapshotPrefix(node.content?.find(block => block.type === 'text')?.text ?? '')
41
- return prefix ? [{ ...prefix, content: node.content }] : []
42
- })[0], [legacy, boundarySeq]) ?? accepted
37
+ const saved = useMemo(() => boundarySeq === undefined ? undefined : savedContextOf(legacy?.nodes ?? [], childWindow?.entries ?? [], childWindow?.hasMore, boundarySeq), [legacy, childWindow, boundarySeq]) ?? accepted
43
38
  const preview = forkedMidTurn && !saved
44
39
  const parent = preview ? ctx.sessions.binding(parentSessionId)?.session : undefined
45
40
  const parentChat = useMemo(() => preview ? chatSourceOf(ctx, ctx.sessions.binding(parentSessionId)) : undefined, [ctx, parentSessionId, parent, preview])
@@ -2,7 +2,7 @@ import { useId, useSyncExternalStore } from 'react'
2
2
  import type { Context } from '../host/contracts.ts'
3
3
  import { registerSessionListFilter } from '../host/sessionList.tsx'
4
4
  import { sessionVisibilityOf, type SessionVisibility } from '../host/sessionVisibility.ts'
5
- import { sideChatMetasAll } from './metaStore.ts'
5
+ import { storedSideChatMetas } from './metaStore.ts'
6
6
  import { allClosedSideChats } from './recentClosed.ts'
7
7
  import { t } from '../locales.ts'
8
8
  import { useLocaleTick } from '../locale-tick.ts'
@@ -23,7 +23,7 @@ function VisibilitySetting({ visibility }: { visibility: SessionVisibility }) {
23
23
  /** Run before orphan-meta cleanup so previous live/closed children can migrate. */
24
24
  export function registerSideSessionVisibility(ctx: Context): void {
25
25
  const visibility = sessionVisibilityOf(ctx)
26
- const ids = [...sideChatMetasAll(), ...allClosedSideChats()].flatMap(record => record.childId ? [record.childId] : [])
26
+ const ids = [...storedSideChatMetas(), ...allClosedSideChats()].flatMap(record => record.childId ? [record.childId] : [])
27
27
  visibility.hide(...ids)
28
28
  ctx.effect(() => visibility.watch(), 'dsh-sidenote: side session visibility')
29
29
  registerSessionListFilter(ctx, visibility)