@retaw106/mmc-pi 2.0.0 → 2.1.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.
@@ -11,3 +11,6 @@
11
11
  .session-card[role="button"]{cursor:pointer}.session-card[role="button"]:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.comp-details-fold,.comp-raw,.ungrouped-raw{margin-top:10px;border-top:1px solid #27312b;padding-top:8px}.comp-details-fold>summary,.comp-raw>summary,.ungrouped-raw>summary{cursor:pointer;list-style:none}.comp-details-fold>summary::-webkit-details-marker,.comp-raw>summary::-webkit-details-marker,.ungrouped-raw>summary::-webkit-details-marker{display:none}.comp-details-fold>summary::before,.comp-raw>summary::before,.ungrouped-raw>summary::before{content:"+";display:inline-block;width:17px;color:var(--accent)}.comp-details-fold[open]>summary::before,.comp-raw[open]>summary::before,.ungrouped-raw[open]>summary::before{content:"-"}.link-summary{color:var(--accent);font-size:10px;line-height:1.6}.comp-details{display:grid;gap:9px;margin-top:9px}.prose{display:grid;gap:3px;padding:8px 10px;border-radius:7px;background:#111714;color:#c4cec3;font-size:11px;line-height:1.65;white-space:pre-wrap}.prose-label{font:600 9px var(--mono);color:#9eae95;letter-spacing:.08em}.comp-message-panel{display:grid;gap:8px;margin-top:10px}.comp-message-panel>.message{margin:0}.boundary-warning{margin:8px 0 0;color:#e8bd79;font-size:10px;line-height:1.6}.ungrouped-raw{margin-top:14px;padding:12px 13px;border:1px solid #5a4930;border-radius:9px;background:#1a1712}.ungrouped-intro{margin:0 0 8px}.ungrouped-raw .comp-message-panel{border-top:1px solid #4a3c29;padding-top:8px}.comp-raw .pager,.ungrouped-raw .pager{margin-bottom:2px}
12
12
  .detail-meta{min-width:0;max-width:100%}.detail-meta>.badge,.detail-meta>.flag{min-width:0;max-width:100%;white-space:normal;overflow-wrap:anywhere;word-break:break-word;text-align:left}.tool-block,.tool-output{min-width:0;max-width:100%}@media(max-width:760px){.detail-pane{min-width:0;overflow-x:hidden}.detail-content{width:100%;min-width:0;max-width:100%;margin:0}.tool-output{overflow-x:auto}}
13
13
  .comp-counts{margin-top:2px;color:#77837b;font:9px var(--mono);cursor:help}.expandable-text{white-space:pre-wrap;overflow-wrap:anywhere}.expandable-preview,.expandable-full{white-space:inherit}.expandable-preview[hidden],.expandable-full[hidden]{display:none}.preview-toggle{border:0;background:none;color:var(--accent);padding:4px 3px;min-height:32px;cursor:pointer;font:10px var(--mono);vertical-align:baseline}.preview-toggle:hover{text-decoration:underline}.preview-toggle:active{transform:scale(.96)}
14
+ .session-counts,.memory-counts{padding:8px 12px;color:#aeb9b0;font:10px var(--mono);border-bottom:1px solid #27312d;background:#111614;white-space:nowrap}.session-tree-node{border-bottom:1px solid #222b27;padding:7px 8px 7px 10px;transition:background .16s,border-color .16s}.session-tree-node:hover{background:#1b231f}.session-tree-node.selected{background:#263321;border-left:3px solid var(--accent);padding-left:7px}.session-tree-node.context-ancestor{opacity:.68}.session-row{display:flex;align-items:flex-start;gap:6px;min-width:0}.session-toggle,.session-toggle-spacer{width:30px;height:30px;min-width:30px;display:grid;place-items:center;border:1px solid #344139;border-radius:7px;background:#171e1a;color:var(--accent);font:16px var(--mono);cursor:pointer}.session-toggle:hover{background:#263321;border-color:#70845b}.session-toggle-spacer{border-color:transparent;background:transparent}.session-open{display:grid;grid-template-columns:minmax(0,1fr);gap:3px;flex:1;min-width:0;padding:2px 3px;border:0;background:none;text-align:left;cursor:pointer}.session-open:hover .item-title{color:#d9ff9c}.session-open .item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.session-open .item-meta{display:flex;justify-content:space-between;gap:8px}.session-time{color:#b7c5b7;white-space:nowrap}.session-time[title]{cursor:help}.session-children{margin:5px 0 0 19px;padding-left:8px;border-left:1px solid #3b493e}.session-children[hidden]{display:none}.session-tree-node[aria-level="3"]{padding-top:5px;padding-bottom:5px}.session-tree-node[aria-level="3"] .session-open .item-title{font-size:11px}.context-label,.relation-note{color:#aab89c;font:9px var(--mono)}.relation-note{color:var(--amber)}.maintenance-badge{color:#f0ce91;background:#3b2d1b;border-color:#74582f}.orphan-group{margin:10px;border:1px solid #5a4930;border-radius:9px;background:#1a1712;overflow:hidden}.orphan-heading{display:flex;align-items:center;gap:7px;padding:8px 9px;color:#efd69d}.orphan-heading strong{font-size:11px}.orphan-body{border-top:1px solid #4a3c29}.orphan-body[hidden]{display:none}.orphan-body .session-tree-node{border-bottom-color:#3d3427}.memory-category-options{display:flex;flex-wrap:wrap;gap:5px;padding:3px 0}.category-chip{display:inline-flex;align-items:center;gap:5px;padding:5px 7px;border:1px solid #334038;border-radius:999px;background:#171e1a;color:#aeb9b0;font-size:10px;cursor:pointer}.category-chip:has(input:checked){border-color:#849d62;background:#293522;color:#e2f7c5}.category-chip input{accent-color:var(--accent);margin:0}.memory-actions{align-items:center}.list-error{margin:10px;border:1px solid #704038;background:#2a1c1a;border-radius:9px;color:#f3c0b7}.list-error strong{color:#ffb8aa}.list-error .link-button{margin-top:7px}.item-list>.item-card{margin-left:10px;margin-right:10px}.branch-box{min-width:0}.session-open .item-sub{overflow-wrap:anywhere}.session-tree-node button:active,.orphan-heading button:active{transform:scale(.96)}
15
+ @media(max-width:760px){.session-counts,.memory-counts{white-space:normal;line-height:1.6}.control-row{flex-wrap:wrap}.list-controls{gap:7px}.memory-category-options{padding:2px 0}.category-chip{min-height:34px}.session-children{margin-left:12px}.session-toggle,.session-toggle-spacer{width:34px;min-width:34px;height:34px}.session-open{padding-top:4px;padding-bottom:4px}}
16
+ .session-facts{color:#b9c9a9}.bound-coordinates{color:#9eaca2;font:9px var(--mono);overflow-wrap:anywhere}.message-index{overflow-wrap:anywhere}
@@ -2,11 +2,26 @@
2
2
  "use strict";
3
3
 
4
4
  const $ = (selector, root = document) => root.querySelector(selector);
5
- const state = { projects: [], sources: {}, fixtureMode: false, project: null, tab: "sessions", sessions: [], memories: [], session: null, memory: null, branch: null, compartments: [], notes: [], segmentViews: new Map(), ungroupedView: null, detailToken: 0, detailBusy: false, hideHistorian: true, draftTimer: 0, writeToken: null, writeBusy: false };
5
+ const state = { projects: [], sources: {}, fixtureMode: false, project: null, tab: "sessions", sessions: [], memories: [], session: null, memory: null, branch: null, compartments: [], notes: [], segmentViews: new Map(), ungroupedView: null, detailToken: 0, detailBusy: false, includeMaintenance: false, sessionTitleQuery: "", sessionHarness: "", sessionSince: "", sessionUntil: "", expandedSessionIds: new Set(), orphanExpanded: false, preFilterExpanded: null, sessionError: null, memoryError: null, memoryQuery: "", memoryStatus: "unarchived", memoryCategories: [], memorySort: "updated-desc", memoryCursor: null, memoryNextCursor: null, memoryHasMore: false, memoryMatchedCount: 0, memoryCategoriesCatalog: [], draftTimer: 0, writeToken: null, writeBusy: false, pendingProjectPath: null };
6
6
  const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined && text !== null) node.textContent = String(text); return node; };
7
7
  const btn = (label, action, className = "button") => { const node = el("button", className, label); node.type = "button"; node.dataset.action = action; return node; };
8
8
  const path = (project, suffix = "") => `/api/projects/${encodeURIComponent(project)}${suffix}`;
9
9
  const formatTime = (value) => { if (value == null || value === "") return "时间未知"; const date = typeof value === "number" ? new Date(value) : new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("zh-CN", { hour12: false }); };
10
+ const VIEW_STATE_KEY = "mmc-panel-view-state";
11
+ function sessionKey(session) { return `${session?.harness || "unknown"}:${session?.sessionId || ""}`; }
12
+ function readViewState() {
13
+ try { const saved = JSON.parse(sessionStorage.getItem(VIEW_STATE_KEY) || "null"); if (!saved || typeof saved !== "object") return; state.pendingProjectPath = saved.projectPath || null; state.pendingSelectedSession = saved.selectedSession || null; state.pendingSelectedMemory = saved.selectedMemory ?? null; state.pendingBranch = saved.branch || null; state.pendingScrollTop = Number(saved.listScrollTop) || 0; state.tab = saved.tab === "memories" ? "memories" : "sessions"; state.includeMaintenance = saved.includeMaintenance === true; state.sessionTitleQuery = typeof saved.sessionTitleQuery === "string" ? saved.sessionTitleQuery : ""; state.sessionHarness = typeof saved.sessionHarness === "string" ? saved.sessionHarness : ""; state.sessionSince = typeof saved.sessionSince === "string" ? saved.sessionSince : ""; state.sessionUntil = typeof saved.sessionUntil === "string" ? saved.sessionUntil : ""; state.expandedSessionIds = new Set(Array.isArray(saved.expandedSessionIds) ? saved.expandedSessionIds : []); state.orphanExpanded = saved.orphanExpanded === true; state.memoryQuery = typeof saved.memoryQuery === "string" ? saved.memoryQuery : ""; state.memoryStatus = typeof saved.memoryStatus === "string" ? saved.memoryStatus : "unarchived"; state.memoryCategories = Array.isArray(saved.memoryCategories) ? saved.memoryCategories : []; state.memorySort = typeof saved.memorySort === "string" ? saved.memorySort : "updated-desc"; state.memoryCursor = null; } catch { /* 浏览器禁用会话存储时仍可正常浏览 */ }
14
+ }
15
+ function saveViewState() {
16
+ try { sessionStorage.setItem(VIEW_STATE_KEY, JSON.stringify({ projectPath: state.project?.projectPath || state.pendingProjectPath, tab: state.tab, includeMaintenance: state.includeMaintenance, sessionTitleQuery: state.sessionTitleQuery || "", sessionHarness: state.sessionHarness || "", sessionSince: state.sessionSince || "", sessionUntil: state.sessionUntil || "", expandedSessionIds: [...state.expandedSessionIds], orphanExpanded: state.orphanExpanded, memoryQuery: state.memoryQuery, memoryStatus: state.memoryStatus, memoryCategories: state.memoryCategories, memorySort: state.memorySort, selectedSession: state.session ? sessionKey(state.session) : null, selectedMemory: state.memory?.id ?? null, branch: state.branch, listScrollTop: $("#items")?.scrollTop || 0 })); } catch { /* 非关键状态,存储失败不阻断面板 */ }
17
+ }
18
+ function sessionTime(session) { return session?.latestMessageTime || { quality: "unreadable", value: null, detail: null }; }
19
+ function sessionTimeText(session) { const time = sessionTime(session); if (time.quality === "no-messages") return "无消息"; if (time.quality === "unreadable") return "时间不可读"; if (time.quality === "partial-unreadable") return time.value == null ? "时间不可读 · 部分不可读" : `${formatTime(time.value)} · 部分不可读`; return time.value == null ? "时间不可读" : formatTime(time.value); }
20
+ function sessionTimeTitle(session) { const time = sessionTime(session); if (time.quality === "normal" && time.value != null) return formatTime(time.value); if (time.quality === "partial-unreadable") return `已知时间仅为下界;部分分支不可读${time.detail ? `:${time.detail}` : ""}`; return time.quality === "no-messages" ? "没有符合条件的 user/assistant 消息" : "无法可靠读取消息时间"; }
21
+ function sessionSort(a, b) { const rank = { normal: 0, "partial-unreadable": 1, "no-messages": 2, unreadable: 3 }; const at = sessionTime(a); const bt = sessionTime(b); const ar = rank[at.quality] ?? 3; const br = rank[bt.quality] ?? 3; if (ar !== br) return ar - br; if (ar < 2 && at.value !== bt.value) return (bt.value ?? -Infinity) - (at.value ?? -Infinity); const ah = `${a.harness || ""}:${a.sessionId || ""}`; const bh = `${b.harness || ""}:${b.sessionId || ""}`; return ah.localeCompare(bh);
22
+ }
23
+ const RELATION_REASON = { "missing-parent": "缺少父会话", "cross-project": "父会话属于其他项目", duplicate: "重复注册", cycle: "父关系成环", "maintenance-parent-hidden": "父为已隐藏的维护会话" };
24
+ const CATEGORY_LABEL = { PROJECT_RULES: "项目规则", ARCHITECTURE: "架构", CONFIG_VALUES: "配置值", ARCHITECTURE_DECISIONS: "架构决策", CONSTRAINTS: "约束", CONFIG_DEFAULTS: "配置默认值", NAMING: "命名", USER_PREFERENCES: "用户偏好", USER_DIRECTIVES: "用户指令", ENVIRONMENT: "环境", WORKFLOW_RULES: "工作流规则", KNOWN_ISSUES: "已知问题" };
10
25
  const PREVIEW_CODEPOINTS = 200;
11
26
  function expandableText(parent, value, className, tag = "div") {
12
27
  const text = value == null ? "" : String(value); const codepoints = Array.from(text); const box = el(tag, `expandable-text${className ? ` ${className}` : ""}`);
@@ -73,6 +88,18 @@
73
88
  }
74
89
  const sessionTitle = (session) => session.title || (session.harness === "pi" ? "Pi 会话 · 无标题" : "OpenCode 2 会话 · 无标题");
75
90
  const number = (value) => value == null ? "—" : new Intl.NumberFormat("zh-CN").format(value);
91
+ function coordinateText(coordinates) {
92
+ const host = coordinates?.hostSeq == null ? "宿主序未知" : `宿主序 #${number(coordinates.hostSeq)}`;
93
+ const raw = coordinates?.rawOrdinal == null ? "原文序未知" : `原文序 #${number(coordinates.rawOrdinal)}`;
94
+ const tags = coordinates?.tagNumbers == null ? "标签未知" : coordinates.tagNumbers.length ? `标签 §${coordinates.tagNumbers.join("、")}§` : "无标签";
95
+ return `${host} · ${raw} · ${tags}`;
96
+ }
97
+ function sessionFacts(session) {
98
+ const identity = session?.childIdentity === "child" ? "子会话" : session?.childIdentity === "root" ? "主会话" : "身份未知";
99
+ const eligibility = session?.processingEligibility === "reduced-subagent" ? "子代理精简资格" : session?.processingEligibility === "primary" ? "主会话资格" : "资格未知";
100
+ return `已登记 · ${identity} · ${eligibility}`;
101
+ }
102
+ const SESSION_FACTS_TITLE = "登记资格,不保证当前启用或健康";
76
103
  const setReadAt = (data) => { if (data?.readAt) $("#read-at").textContent = `读取于 ${formatTime(data.readAt)}`; };
77
104
  async function api(url) {
78
105
  const response = await fetch(url, { method: "GET", cache: "no-store", headers: { Accept: "application/json" } });
@@ -198,72 +225,99 @@
198
225
  }
199
226
  function setLevel(level) { document.body.dataset.level = level; }
200
227
  async function loadProjects(keep = true) {
201
- loading($("#projects"), "读取项目与数据源…");
228
+ const hadOld = state.projects.length > 0; if (!hadOld) loading($("#projects"), "读取项目与数据源…");
202
229
  try {
203
230
  const data = await api("/api/projects"); state.projects = data.projects || []; state.sources = data.sources || {}; state.fixtureMode = data.fixtureMode === true; renderFixtureNotice(); renderSources(); renderProjects();
204
- if (keep && state.project) {
231
+ if (!state.project && state.pendingProjectPath) {
232
+ const match = state.projects.find((item) => item.projectPath === state.pendingProjectPath); if (match) await loadProject(match);
233
+ } else if (keep && state.project) {
205
234
  const match = state.projects.find((item) => item.projectPath === state.project.projectPath);
206
235
  if (match) { state.project = match; await loadCurrent(); }
207
236
  else { state.project = null; state.session = state.memory = null; renderProjects(); showNoSelection(); }
208
237
  }
209
- } catch (error) { state.projects = []; renderProjects(); $("#source-status").replaceChildren(el("div", "error-state", `项目读取失败:${error.message}`)); }
238
+ } catch (error) { if (!hadOld) state.projects = []; renderProjects(); const box = el("div", "error-state"); box.append(el("strong", "", hadOld ? "读取失败,以下为旧项目列表" : "项目读取失败"), el("p", "", error.message)); const retry = btn("重试", "retry-projects", "link-button"); box.append(retry); $("#source-status").replaceChildren(box); }
210
239
  }
211
- function showNoSelection() { setListEyebrow(state.project); $("#list-title").textContent = "审阅内容"; $("#items").replaceChildren(); $("#list-controls").replaceChildren(); const detail = $("#detail"); detail.classList.add("detail-empty"); detail.replaceChildren(); const box = el("div", "detail-empty"); box.append(el("span", "empty-glyph", "⌁"), el("h2", "", state.project ? `选择一个${state.tab === "sessions" ? "会话" : "记忆"}` : "选择一个项目"), el("p", "", state.project ? "从中间列表选择一条记录查看详情。" : "从左侧选择项目,查看会话或记忆。")); detail.append(box); }
240
+ function showNoSelection() { setListEyebrow(state.project); $("#list-title").textContent = state.project ? (state.tab === "sessions" ? "会话" : "记忆") : "审阅内容"; if (!state.project) $("#list-controls").replaceChildren(); $("#items").replaceChildren(); const detail = $("#detail"); detail.classList.add("detail-empty"); detail.replaceChildren(); const box = el("div", "detail-empty"); box.append(el("span", "empty-glyph", "⌁"), el("h2", "", state.project ? `选择一个${state.tab === "sessions" ? "会话" : "记忆"}` : "选择一个项目"), el("p", "", state.project ? "从中间列表选择一条记录查看详情。" : "从左侧选择项目,查看会话或记忆。")); detail.append(box); }
212
241
  function renderListControls() {
213
242
  const root = $("#list-controls"); root.replaceChildren();
214
243
  root.append(btn("← 项目", "back-projects", "mobile-back"));
215
244
  if (state.tab === "sessions") {
216
- const query = el("input", "field"); query.placeholder = "按标题搜索"; query.type = "search"; query.dataset.filter = "title";
217
- const harness = el("select", "field"); harness.dataset.filter = "harness"; for (const [value, label] of [["", "全部宿主"], ["opencode2", "OpenCode 2"], ["pi", "Pi"]]) { const option = el("option", "", label); option.value = value; harness.append(option); }
218
- const since = el("input", "field"); since.type = "date"; since.dataset.filter = "since"; since.setAttribute("aria-label", "从日期筛选");
219
- const until = el("input", "field"); until.type = "date"; until.dataset.filter = "until"; until.setAttribute("aria-label", "至日期筛选");
245
+ const query = el("input", "field"); query.placeholder = "按标题搜索"; query.type = "search"; query.dataset.filter = "title"; query.value = state.sessionTitleQuery || ""; query.setAttribute("aria-label", "按标题搜索会话");
246
+ const harness = el("select", "field"); harness.dataset.filter = "harness"; for (const [value, label] of [["", "全部宿主"], ["opencode2", "OpenCode 2"], ["pi", "Pi"]]) { const option = el("option", "", label); option.value = value; option.selected = value === (state.sessionHarness || ""); harness.append(option); }
247
+ const since = el("input", "field"); since.type = "date"; since.dataset.filter = "since"; since.value = state.sessionSince || ""; since.setAttribute("aria-label", "从日期筛选");
248
+ const until = el("input", "field"); until.type = "date"; until.dataset.filter = "until"; until.value = state.sessionUntil || ""; until.setAttribute("aria-label", "至日期筛选");
220
249
  const row1 = el("div", "control-row"); row1.append(query); const row2 = el("div", "control-row"); row2.append(harness); const row3 = el("div", "control-row"); row3.append(since, until);
221
- const check = el("label", "checkline"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = state.hideHistorian; checkbox.dataset.filter = "historian"; check.append(checkbox, el("span", "", "隐藏 historian 派生会话"));
222
- root.append(row1, row2, row3, check);
223
- for (const filter of root.querySelectorAll("[data-filter]")) filter.addEventListener("input", renderItems);
224
- harness.addEventListener("change", renderItems); checkbox.addEventListener("change", () => { state.hideHistorian = checkbox.checked; renderItems(); });
250
+ const check = el("label", "checkline"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = state.includeMaintenance; checkbox.dataset.filter = "maintenance"; check.append(checkbox, el("span", "", "显示维护会话"));
251
+ const clear = btn("清除筛选", "clear-session-filters", "link-button"); clear.hidden = !sessionFiltersActive();
252
+ root.append(row1, row2, row3, check, clear);
253
+ query.addEventListener("input", () => { state.sessionTitleQuery = query.value; onSessionFilterChange(); });
254
+ harness.addEventListener("change", () => { state.sessionHarness = harness.value; onSessionFilterChange(); });
255
+ since.addEventListener("change", () => { state.sessionSince = since.value; onSessionFilterChange(); });
256
+ until.addEventListener("change", () => { state.sessionUntil = until.value; onSessionFilterChange(); });
257
+ checkbox.addEventListener("change", () => { state.includeMaintenance = checkbox.checked; saveViewState(); loadSessions(); });
225
258
  } else {
226
- const query = el("input", "field"); query.type = "search"; query.placeholder = "搜索当前项目记忆"; query.dataset.filter = "memory-q"; query.value = state.memoryQuery || "";
227
- const category = el("select", "field"); category.dataset.filter = "memory-category"; for (const [value, label] of [["", "全部类别"], ["PROJECT_RULES", "项目规则"], ["ARCHITECTURE", "架构"], ["CONSTRAINTS", "约束"], ["CONFIG_VALUES", "配置值"], ["NAMING", "命名"]]) { const option = el("option", "", label); option.value = value; category.append(option); }
228
- const status = el("select", "field"); status.dataset.filter = "memory-status"; for (const [value, label] of [["unarchived", "未归档(默认)"], ["expired-unarchived", "过期未归档"], ["archived", "已归档"], ["all", "全部状态"]]) { const option = el("option", "", label); option.value = value; status.append(option); }
229
- const row = el("div", "control-row"); row.append(query); const row2 = el("div", "control-row"); row2.append(category, status); const search = btn("检索", "search-memories", "button"); root.append(row, row2, search);
230
- query.addEventListener("keydown", (event) => { if (event.key === "Enter") loadMemories(); }); category.addEventListener("change", loadMemories); status.addEventListener("change", loadMemories);
259
+ const query = el("input", "field"); query.type = "search"; query.placeholder = "搜索当前项目记忆"; query.value = state.memoryQuery || ""; query.dataset.filter = "memory-q"; query.setAttribute("aria-label", "搜索当前项目记忆");
260
+ const status = el("select", "field"); status.dataset.filter = "memory-status"; for (const [value, label] of [["unarchived", "未归档(默认)"], ["expired-unarchived", "过期未归档"], ["archived", "已归档"], ["all", "全部状态"]]) { const option = el("option", "", label); option.value = value; option.selected = value === state.memoryStatus; status.append(option); }
261
+ const sort = el("select", "field"); sort.dataset.filter = "memory-sort"; for (const [value, label] of [["updated-desc", "最近更新"], ["importance-desc", "重要性"], ["created-desc", "创建时间"]]) { const option = el("option", "", label); option.value = value; option.selected = value === state.memorySort; sort.append(option); }
262
+ const row = el("div", "control-row"); row.append(query); const row2 = el("div", "control-row"); row2.append(status, sort);
263
+ const categories = el("div", "memory-category-options"); categories.setAttribute("aria-label", "按类别筛选"); for (const value of state.memoryCategoriesCatalog) { const label = el("label", "category-chip"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.value = value; checkbox.checked = state.memoryCategories.includes(value); checkbox.dataset.category = value; label.append(checkbox, el("span", "", CATEGORY_LABEL[value] || value)); categories.append(label); }
264
+ const actions = el("div", "control-row memory-actions"); const search = btn("检索", "search-memories", "button button-primary"); const clear = btn("清除筛选", "clear-memory-filters", "link-button"); clear.hidden = !(state.memoryQuery || state.memoryCategories.length || state.memoryStatus !== "unarchived" || state.memorySort !== "updated-desc"); actions.append(search, clear);
265
+ root.append(row, row2, categories, actions);
266
+ query.addEventListener("input", () => { state.memoryQuery = query.value; saveViewState(); }); query.addEventListener("keydown", (event) => { if (event.key === "Enter") loadMemories(true); }); status.addEventListener("change", () => { state.memoryStatus = status.value; saveViewState(); loadMemories(true); }); sort.addEventListener("change", () => { state.memorySort = sort.value; saveViewState(); loadMemories(true); });
267
+ for (const checkbox of categories.querySelectorAll("input")) checkbox.addEventListener("change", () => { state.memoryCategories = [...categories.querySelectorAll("input:checked")].map((item) => item.value); saveViewState(); loadMemories(true); });
231
268
  }
232
269
  }
270
+ function sessionFiltersActive() { return Boolean((state.sessionTitleQuery || "").trim() || state.sessionHarness || state.sessionSince || state.sessionUntil); }
271
+ function onSessionFilterChange() { const active = sessionFiltersActive(); if (active && !state.preFilterExpanded) state.preFilterExpanded = new Set(state.expandedSessionIds); if (!active && state.preFilterExpanded) { state.expandedSessionIds = state.preFilterExpanded; state.preFilterExpanded = null; } const clear = $('[data-action="clear-session-filters"]', $("#list-controls")); if (clear) clear.hidden = !active; saveViewState(); renderItems(); }
272
+ function dateBoundary(value, end = false) { if (!value) return null; const parts = value.split("-").map(Number); if (parts.length !== 3 || parts.some((item) => !Number.isInteger(item))) return null; return new Date(parts[0], parts[1] - 1, parts[2] + (end ? 1 : 0), 0, 0, 0, 0).getTime(); }
273
+ function sessionMatches(session) { const title = (state.sessionTitleQuery || "").trim().toLocaleLowerCase(); const time = sessionTime(session); if (state.sessionHarness && session.harness !== state.sessionHarness) return false; if (title && !(session.title || "").toLocaleLowerCase().includes(title)) return false; const since = dateBoundary(state.sessionSince); const until = dateBoundary(state.sessionUntil, true); if (since != null && (time.value == null || time.value < since)) return false; if (until != null && (time.value == null || time.value >= until)) return false; return true; }
274
+ function makeSessionTree() {
275
+ const nodes = (state.sessions || []).filter((node) => state.includeMaintenance || !node.maintenance); const byId = new Map(); for (const node of nodes) { const list = byId.get(node.sessionId) || []; list.push(node); byId.set(node.sessionId, list); }
276
+ const children = new Map(); const parentOf = new Map();
277
+ for (const node of nodes) { if (node.relation?.state !== "child" || !node.parentId) continue; const parents = byId.get(node.parentId) || []; if (parents.length !== 1) continue; parentOf.set(sessionKey(node), parents[0]); const key = sessionKey(parents[0]); const list = children.get(key) || []; list.push(node); children.set(key, list); }
278
+ for (const list of children.values()) list.sort(sessionSort);
279
+ const matches = new Map(); let rootsMatched = 0; let childrenMatched = 0;
280
+ for (const node of nodes) { const matched = sessionMatches(node); matches.set(sessionKey(node), matched); if (matched && node.relation?.state === "root") rootsMatched++; if (matched && node.relation?.state !== "root") childrenMatched++; }
281
+ const descendantMatch = (node, seen = new Set()) => { const key = sessionKey(node); if (seen.has(key)) return false; seen.add(key); if (matches.get(key)) return true; return (children.get(key) || []).some((child) => descendantMatch(child, new Set(seen))); };
282
+ const included = new Set(); let contextAncestors = 0;
283
+ const includeNode = (node) => { const key = sessionKey(node); if (included.has(key)) return; if (!sessionFiltersActive() || descendantMatch(node)) { included.add(key); if (!matches.get(key)) contextAncestors++; const parent = parentOf.get(key); if (parent) includeNode(parent); } };
284
+ for (const node of nodes) if (node.relation?.state === "root" || node.relation?.state === "orphan") includeNode(node);
285
+ for (const node of nodes) if (matches.get(sessionKey(node))) includeNode(node);
286
+ if (!sessionFiltersActive()) for (const node of nodes) included.add(sessionKey(node));
287
+ if (sessionFiltersActive()) for (const node of nodes) if (matches.get(sessionKey(node))) { let current = node; while (current) { if (current.relation?.state === "orphan") state.orphanExpanded = true; if (current !== node) state.expandedSessionIds.add(sessionKey(current)); current = parentOf.get(sessionKey(current)); } }
288
+ const build = (node) => ({ node, matched: matches.get(sessionKey(node)) === true, context: included.has(sessionKey(node)) && !matches.get(sessionKey(node)), children: (children.get(sessionKey(node)) || []).filter((child) => included.has(sessionKey(child))).map(build) });
289
+ return { roots: nodes.filter((node) => node.relation?.state === "root" && included.has(sessionKey(node))).sort(sessionSort).map(build), orphans: nodes.filter((node) => node.relation?.state === "orphan" && included.has(sessionKey(node))).sort(sessionSort).map(build), rootsMatched, childrenMatched, contextAncestors };
290
+ }
233
291
  function renderItems() {
234
292
  const root = $("#items"); root.replaceChildren();
235
293
  if (state.tab === "sessions") {
236
- const title = ($('[data-filter="title"]', $("#list-controls"))?.value || "").trim().toLocaleLowerCase(); const harness = $('[data-filter="harness"]', $("#list-controls"))?.value || "";
237
- const since = $('[data-filter="since"]', $("#list-controls"))?.value; const until = $('[data-filter="until"]', $("#list-controls"))?.value; const hide = $('[data-filter="historian"]', $("#list-controls"))?.checked ?? state.hideHistorian;
238
- const filtered = state.sessions.filter((s) => (!hide || s.derived !== "historian") && (!harness || s.harness === harness) && (!title || (s.title || "").toLocaleLowerCase().includes(title)) && (!since || (s.lastActivityAt ?? 0) >= new Date(`${since}T00:00:00`).getTime()) && (!until || (s.lastActivityAt ?? 0) <= new Date(`${until}T23:59:59`).getTime()));
239
- if (!filtered.length) { empty(root, state.sessions.length ? "没有符合条件的会话" : "暂无会话", state.sessions.length ? "调整筛选条件试试。" : "这个项目当前没有登记会话;如有记忆,可切换到记忆查看。"); return; }
240
- for (const session of filtered) renderSessionCard(root, session);
294
+ root.setAttribute("role", "tree"); root.setAttribute("aria-label", "会话树"); const tree = makeSessionTree();
295
+ const counts = el("div", "session-counts", `匹配主会话 ${number(tree.rootsMatched)} · 子会话 ${number(tree.childrenMatched)} · 定位祖先 ${number(tree.contextAncestors)}`); root.append(counts);
296
+ if (!tree.roots.length && !tree.orphans.length) { if (state.sessionError) renderListError(root, state.sessionError, false); else empty(root, state.sessions.length ? "没有符合条件的会话" : "暂无会话", state.sessions.length ? "调整筛选条件试试。" : "这个项目当前没有登记会话;如有记忆,可切换到记忆查看。"); return; }
297
+ for (const item of tree.roots) renderSessionNode(root, item, 1);
298
+ if (tree.orphans.length) renderOrphanGroup(root, tree.orphans);
299
+ if (state.sessionError) renderListError(root, state.sessionError, true);
241
300
  } else {
242
- if (!state.memories.length) { empty(root, state.memoryQuery ? "没有匹配的记忆" : "没有记忆", state.memoryQuery ? "检索没有结果,不代表数据源故障。" : "尝试更换状态筛选或搜索词。"); return; }
301
+ root.removeAttribute("role"); const summary = el("div", "memory-counts", `匹配 ${number(state.memoryMatchedCount)} · 已加载 ${number(state.memories.length)}`); root.append(summary);
302
+ if (!state.memories.length) { if (state.memoryError) renderListError(root, state.memoryError, false); else empty(root, state.memoryMatchedCount ? "当前页没有可显示的记忆" : "没有匹配的记忆", state.memoryMatchedCount ? "请尝试重新检索。" : "调整搜索、类别或状态筛选。"); return; }
243
303
  for (const memory of state.memories) renderMemoryCard(root, memory);
304
+ if (state.memoryHasMore) { const more = btn("加载更多", "load-more-memories", "button"); more.setAttribute("aria-label", `加载更多记忆,已加载 ${state.memories.length} 条`); root.append(more); }
305
+ if (state.memoryError) renderListError(root, state.memoryError, true);
244
306
  }
245
307
  }
246
- function renderSessionCard(root, session) {
247
- const active = state.session?.sessionId === session.sessionId;
248
- const canOpenCard = true;
249
- const card = el("article", `item-card session-card${active ? " selected" : ""}`);
250
- card.dataset.sid = session.sessionId;
251
- if (canOpenCard) { card.dataset.action = "session"; card.tabIndex = 0; card.setAttribute("role", "button"); card.setAttribute("aria-label", `打开会话:${sessionTitle(session)}`); }
252
- card.append(el("span", "item-title", sessionTitle(session)));
253
- const meta = el("span", "item-meta"); meta.append(el("span", "", session.harness === "pi" ? "Pi" : "OpenCode 2"), el("span", "", formatTime(session.lastActivityAt))); card.append(meta);
254
- const foot = el("span", "item-sub", `${session.sessionId} · ${number(session.compartmentCount)} 段摘要`); card.append(foot);
255
- if (session.derived) card.append(el("span", "badge", session.derived === "child" ? "子会话" : "historian"));
256
- if (session.harness === "pi") {
257
- const branches = session.piBranches || [];
258
- const box = el("div", "branch-box");
259
- const label = el("span", "item-sub", branches.length ? `Pi 分支文件 · ${branches.length}` : "Pi 原文文件未找到"); box.append(label);
260
- for (const branch of branches) {
261
- const b = btn(`${branch.fileName} · ${branch.directoryName}`, "branch", "link-button branch-button"); b.dataset.sid = session.sessionId; b.dataset.branch = branch.fileName; b.title = branch.headerTimestamp || "时间未知"; box.append(b);
262
- }
263
- card.append(box);
264
- }
265
- root.append(card);
266
- }
308
+ function renderListError(root, error, oldData) { const box = el("div", "error-state list-error"); box.append(el("strong", "", oldData ? "读取失败,以下为旧查询结果" : "读取失败"), el("p", "", error.message)); const retry = btn("重试", state.tab === "sessions" ? "retry-sessions" : "retry-memories", "link-button"); box.append(retry); root.append(box); }
309
+ function renderSessionNode(parent, item, level) {
310
+ const session = item.node; const key = sessionKey(session); const children = item.children; const expanded = state.expandedSessionIds.has(key); const active = state.session && sessionKey(state.session) === key; const id = `session-children-${encodeURIComponent(key).replace(/%/g, "")}`;
311
+ const node = el("div", `session-tree-node${active ? " selected" : ""}${item.context ? " context-ancestor" : ""}`); node.dataset.sid = session.sessionId; node.dataset.harness = session.harness; node.dataset.sessionKey = key; node.setAttribute("role", "treeitem"); node.setAttribute("aria-level", String(level)); node.setAttribute("aria-selected", String(Boolean(active))); node.tabIndex = 0;
312
+ const row = el("div", "session-row");
313
+ if (children.length) { const toggle = btn(expanded ? "−" : "+", "toggle-session", "session-toggle"); toggle.dataset.sessionKey = key; toggle.setAttribute("aria-expanded", String(expanded)); toggle.setAttribute("aria-controls", id); toggle.setAttribute("aria-label", `${expanded ? "收起" : "展开"}${sessionTitle(session)}的子会话`); row.append(toggle); } else row.append(el("span", "session-toggle-spacer"));
314
+ const open = btn("", "session", "session-open"); open.dataset.sid = session.sessionId; open.dataset.harness = session.harness; open.setAttribute("aria-label", `打开会话:${sessionTitle(session)}`); open.append(el("span", "item-title", sessionTitle(session)));
315
+ const meta = el("span", "item-meta"); const time = el("time", "session-time", sessionTimeText(session)); time.title = sessionTimeTitle(session); meta.append(el("span", "", session.harness === "pi" ? "Pi" : "OpenCode 2"), time); open.append(meta);
316
+ const foot = el("span", "item-sub", `${session.sessionId} · ${number(session.compartmentCount)} 段摘要${children.length ? ` · ${number(children.length)} 个子会话` : ""}`); open.append(foot); const facts = el("span", "item-sub session-facts", sessionFacts(session)); facts.title = SESSION_FACTS_TITLE; open.append(facts); if (item.context) open.append(el("span", "context-label", "用于定位")); if (session.maintenance) open.append(el("span", "badge maintenance-badge", `维护 · ${session.maintenance}`)); if (session.relation?.state === "orphan") open.append(el("span", "relation-note", RELATION_REASON[session.relation.reason] || "未归属子会话")); if (session.forkSessionId) { const source = el("span", "item-sub", `来源:${session.forkSessionId}`); source.title = "forkSessionId 仅表示来源,不是父子关系"; open.append(source); } row.append(open); node.append(row);
317
+ if (session.harness === "pi") { const branches = session.piBranches || []; const box = el("div", "branch-box"); box.append(el("span", "item-sub", branches.length ? `Pi 分支文件 · ${branches.length}` : "Pi 原文文件未找到")); for (const branch of branches) { const b = btn(`${branch.fileName} · ${branch.directoryName}`, "branch", "link-button branch-button"); b.dataset.sid = session.sessionId; b.dataset.harness = session.harness; b.dataset.branch = branch.fileName; b.title = branch.headerTimestamp || "时间未知"; box.append(b); } node.append(box); }
318
+ if (children.length) { const group = el("div", "session-children"); group.id = id; group.setAttribute("role", "group"); group.hidden = !expanded; if (expanded) for (const child of children) renderSessionNode(group, child, level + 1); node.append(group); } parent.append(node);
319
+ }
320
+ function renderOrphanGroup(root, items) { const group = el("section", "orphan-group"); group.setAttribute("aria-label", "未归属子会话"); const heading = el("div", "orphan-heading"); const toggle = btn(state.orphanExpanded ? "−" : "+", "toggle-orphans", "session-toggle"); toggle.setAttribute("aria-expanded", String(state.orphanExpanded)); toggle.setAttribute("aria-controls", "orphan-session-group"); toggle.setAttribute("aria-label", `${state.orphanExpanded ? "收起" : "展开"}未归属子会话`); heading.append(toggle, el("strong", "", `未归属子会话 · ${items.length}`)); group.append(heading); const body = el("div", "orphan-body"); body.id = "orphan-session-group"; body.setAttribute("role", "group"); body.hidden = !state.orphanExpanded; if (state.orphanExpanded) for (const item of items) renderSessionNode(body, item, 1); group.append(body); root.append(group); }
267
321
  function renderMemoryCard(root, memory) {
268
322
  const card = btn("", "memory", `item-card${state.memory?.id === memory.id ? " selected" : ""}`); card.dataset.mid = memory.id;
269
323
  card.append(el("span", "item-title", `#${memory.id} · ${memory.category}`));
@@ -273,49 +327,56 @@
273
327
  if (memory.mergedFrom) card.append(el("span", "badge", "合并记录")); root.append(card);
274
328
  }
275
329
  async function loadProject(project) {
276
- state.detailToken += 1; state.project = project; state.session = null; state.memory = null; state.branch = null; state.segmentViews = new Map(); state.ungroupedView = null; renderProjects(); setListEyebrow(project); $("#list-title").textContent = "会话"; renderListControls(); loading($("#items")); showNoSelection(); setLevel("list"); await loadCurrent();
330
+ const restoring = !state.project && state.pendingProjectPath === project?.projectPath; state.detailToken += 1; state.project = project; state.pendingProjectPath = project?.projectPath || null; state.session = null; state.memory = null; state.branch = null; if (!restoring) { state.expandedSessionIds = new Set(); state.orphanExpanded = false; } state.preFilterExpanded = null; state.sessionError = null; state.memoryError = null; state.sessions = []; state.memories = []; state.memoryCursor = null; state.memoryNextCursor = null; state.memoryHasMore = false; state.memoryMatchedCount = 0; renderProjects(); setListEyebrow(project); $("#list-title").textContent = state.tab === "sessions" ? "会话" : "记忆"; renderListControls(); loading($("#items")); showNoSelection(); setLevel("list"); saveViewState(); await loadCurrent();
331
+ if (state.tab === "sessions" && state.pendingSelectedSession) { const selected = state.sessions.find((item) => sessionKey(item) === state.pendingSelectedSession); if (selected) await openSession(selected, state.pendingBranch); }
332
+ if (state.tab === "memories" && state.pendingSelectedMemory != null) { const selected = state.memories.find((item) => item.id === Number(state.pendingSelectedMemory)); if (selected) await openMemory(selected); }
333
+ state.pendingSelectedSession = null; state.pendingSelectedMemory = null; state.pendingBranch = null; if (state.pendingScrollTop) requestAnimationFrame(() => { $("#items").scrollTop = state.pendingScrollTop; });
277
334
  }
278
335
  async function loadCurrent() { if (!state.project) return; if (state.tab === "sessions") await loadSessions(); else await loadMemories(); }
279
336
  async function loadSessions() {
280
- loading($("#items"), "读取会话列表…");
281
- try { const data = await api(path(state.project.projectPath, "/sessions")); state.sessions = data.sessions || []; renderItems(); }
282
- catch (error) { errorBox($("#items"), error); }
337
+ const hadOld = state.sessions.length > 0; if (!hadOld) loading($("#items"), "读取会话列表…");
338
+ try { const suffix = state.includeMaintenance ? "/sessions?includeMaintenance=1" : "/sessions"; const data = await api(path(state.project.projectPath, suffix)); state.sessions = data.sessions || []; state.sessionError = null; renderItems(); saveViewState(); }
339
+ catch (error) { state.sessionError = error; if (hadOld) renderItems(); else { const root = $("#items"); root.replaceChildren(); renderListError(root, error, false); } }
283
340
  }
284
- async function loadMemories() {
341
+ async function loadMemories(reset = true) {
285
342
  if (!state.project) return; const controls = $("#list-controls"); const query = $('[data-filter="memory-q"]', controls)?.value || ""; state.memoryQuery = query;
286
- const category = $('[data-filter="memory-category"]', controls)?.value || ""; const status = $('[data-filter="memory-status"]', controls)?.value || "unarchived";
287
- const params = new URLSearchParams({ status }); if (query.trim()) params.set("q", query.trim()); if (category) params.set("category", category);
288
- loading($("#items"), "检索当前项目记忆…");
289
- try { const data = await api(path(state.project.projectPath, `/memories?${params}`)); state.memories = data.memories || []; renderItems(); if (data.truncated) $("#items").append(el("div", "loading", "结果过多,仅显示当前页上限;请缩小搜索范围。")); }
290
- catch (error) { errorBox($("#items"), error); }
343
+ state.memoryStatus = $('[data-filter="memory-status"]', controls)?.value || state.memoryStatus || "unarchived"; state.memorySort = $('[data-filter="memory-sort"]', controls)?.value || state.memorySort || "updated-desc";
344
+ const hadOld = state.memories.length > 0; if (reset) { state.memoryCursor = null; state.memoryHasMore = false; }
345
+ const params = new URLSearchParams({ status: state.memoryStatus, sort: state.memorySort }); if (query.trim()) params.set("q", query.trim()); for (const category of state.memoryCategories) params.append("category", category); if (!reset && state.memoryCursor) params.set("cursor", state.memoryCursor);
346
+ if (!hadOld) loading($("#items"), "检索当前项目记忆…");
347
+ try { const data = await api(path(state.project.projectPath, `/memories?${params}`)); const byId = new Map(reset ? [] : state.memories.map((memory) => [memory.id, memory])); for (const memory of data.memories || []) byId.set(memory.id, memory); state.memories = [...byId.values()]; state.memoryCategoriesCatalog = Array.isArray(data.categories) ? data.categories : state.memoryCategoriesCatalog; state.memoryMatchedCount = Number.isFinite(data.matchedCount) ? data.matchedCount : state.memories.length; state.memoryNextCursor = data.nextCursor || null; state.memoryCursor = data.nextCursor || null; state.memoryHasMore = data.hasMore === true; state.memoryError = null; renderListControls(); renderItems(); saveViewState(); }
348
+ catch (error) { state.memoryError = error; if (hadOld) renderItems(); else { const root = $("#items"); root.replaceChildren(); renderListError(root, error, false); } }
291
349
  }
292
350
  function tabTo(tab) {
293
351
  state.detailToken += 1; state.tab = tab; state.session = null; state.memory = null; state.branch = null; state.segmentViews = new Map(); state.ungroupedView = null;
294
- $("#sessions-tab").classList.toggle("active", tab === "sessions"); $("#sessions-tab").setAttribute("aria-selected", String(tab === "sessions"));
352
+ state.memoryError = state.sessionError = null; state.memoryCursor = null; $("#sessions-tab").classList.toggle("active", tab === "sessions"); $("#sessions-tab").setAttribute("aria-selected", String(tab === "sessions"));
295
353
  $("#memories-tab").classList.toggle("active", tab === "memories"); $("#memories-tab").setAttribute("aria-selected", String(tab === "memories"));
296
- $("#list-title").textContent = tab === "sessions" ? "会话" : "记忆"; renderListControls(); $("#detail").replaceChildren(); loadCurrent();
354
+ $("#list-title").textContent = tab === "sessions" ? "会话" : "记忆"; renderListControls(); $("#detail").replaceChildren(); saveViewState(); loadCurrent();
297
355
  }
298
356
  function section(parent, title, note = "") { const wrap = el("section", "section"); const head = el("div", "section-head"); head.append(el("h3", "", title)); if (note) head.append(el("span", "section-note", note)); wrap.append(head); parent.append(wrap); return wrap; }
299
357
  function addMobileBack(parent, target, label) { const back = btn(`← ${label}`, `back-${target}`, "mobile-back"); parent.append(back); }
358
+ function ensureSessionAncestorsExpanded(session) { const byId = new Map((state.sessions || []).map((item) => [sessionKey(item), item])); let current = session; const seen = new Set(); while (current && current.parentId && !seen.has(sessionKey(current))) { seen.add(sessionKey(current)); const parent = [...byId.values()].find((item) => item.sessionId === current.parentId); if (!parent) break; state.expandedSessionIds.add(sessionKey(parent)); current = parent; } if (session?.relation?.state === "orphan") state.orphanExpanded = true; }
300
359
  function showDetailLoading(label = "正在读取详情…") { const root = $("#detail"); root.classList.add("detail-empty"); root.replaceChildren(el("div", "loading", label)); }
301
360
  async function openSession(session, branch = null) {
302
- if (!session) return;
303
- const sameSession = state.session?.sessionId === session.sessionId && state.branch === branch;
361
+ if (!session) return;
362
+ const previousSession = state.session; const previousBranch = state.branch; const previousCompartments = state.compartments; const previousNotes = state.notes; const previousDetailErrors = state.detailErrors;
363
+ const sameSession = state.session?.sessionId === session.sessionId && state.branch === branch;
304
364
  if (!sameSession) { state.segmentViews = new Map(); state.ungroupedView = null; }
305
365
  const token = ++state.detailToken;
306
- state.session = session; state.memory = null; state.branch = branch; state.detailBusy = true; renderItems(); setLevel("detail"); showDetailLoading();
366
+ state.session = session; state.memory = null; state.branch = branch; ensureSessionAncestorsExpanded(session); state.detailBusy = true; saveViewState(); renderItems(); setLevel("detail"); showDetailLoading();
307
367
  if (session.harness === "pi" && !branch) { state.compartments = []; state.notes = []; state.detailErrors = {}; state.detailBusy = false; renderSessionDetail(); toast("Pi 会话需要先选择分支;未猜测默认分支。", 5000); return; }
308
368
  const suffix = `/sessions/${encodeURIComponent(session.sessionId)}`;
309
- const calls = [api(path(state.project.projectPath, `${suffix}/compartments${branch ? `?branch=${encodeURIComponent(branch)}` : ""}`)), api(path(state.project.projectPath, `${suffix}/notes`))];
310
- const [compartments, notes] = await Promise.all(calls.map((p) => p.then((value) => ({ value }), (error) => ({ error }))));
311
- if (token !== state.detailToken || state.session?.sessionId !== session.sessionId || state.branch !== branch) return;
312
- state.compartments = compartments.value?.compartments || []; state.compartmentsApproximate = compartments.value?.renderTierApproximate === true; state.notes = notes.value?.notes || []; state.detailErrors = { compartments: compartments.error, notes: notes.error }; state.detailBusy = false; renderSessionDetail();
369
+ const calls = [api(path(state.project.projectPath, `${suffix}/compartments${branch ? `?branch=${encodeURIComponent(branch)}` : ""}`)), api(path(state.project.projectPath, `${suffix}/notes`))];
370
+ const [compartments, notes] = await Promise.all(calls.map((p) => p.then((value) => ({ value }), (error) => ({ error }))));
371
+ if (token !== state.detailToken || state.session?.sessionId !== session.sessionId || state.branch !== branch) return;
372
+ if (compartments.error && notes.error && previousSession) { state.session = previousSession; state.branch = previousBranch; state.compartments = previousCompartments; state.notes = previousNotes; state.detailErrors = previousDetailErrors; state.detailBusy = false; renderItems(); renderSessionDetail(); toast(`详情读取失败,仍显示旧会话:${compartments.error.message}`, 6000); return; }
373
+ state.compartments = compartments.value?.compartments || []; state.compartmentsApproximate = compartments.value?.renderTierApproximate === true; state.notes = notes.value?.notes || []; state.detailErrors = { compartments: compartments.error, notes: notes.error }; state.detailBusy = false; renderSessionDetail();
313
374
  }
314
375
  function renderSessionDetail() {
315
376
  const root = $("#detail"); root.classList.remove("detail-empty"); root.replaceChildren(); if (!state.session) return;
316
377
  const wrap = el("div", "detail-content"); addMobileBack(wrap, "list", "返回列表");
317
378
  const s = state.session; wrap.append(el("div", "detail-kicker", `${s.harness === "pi" ? "PI SESSION" : "OPENCODE 2 SESSION"}${state.branch ? " · BRANCH SELECTED" : ""}`));
318
- wrap.append(el("h2", "detail-title", sessionTitle(s))); const meta = el("div", "detail-meta"); meta.append(el("span", "badge", s.sessionId), el("span", "badge", `${number(s.compartmentCount)} 个摘要段`), el("span", "", formatTime(s.lastActivityAt))); if (state.branch) meta.append(el("span", "badge", state.branch)); wrap.append(meta);
379
+ wrap.append(el("h2", "detail-title", sessionTitle(s))); const meta = el("div", "detail-meta"); const eligibility = el("span", "badge", sessionFacts(s).replace("已登记 · ", "")); eligibility.title = SESSION_FACTS_TITLE; meta.append(el("span", "badge", s.sessionId), el("span", "badge", "已登记"), eligibility, el("span", "badge", `${number(s.compartmentCount)} 个摘要段`), el("span", "", sessionTimeText(s))); if (state.branch) meta.append(el("span", "badge", state.branch)); wrap.append(meta);
319
380
  if (s.harness === "pi" && !state.branch) { const prompt = section(wrap, "选择 Pi 分支", "Pi 原文与摘要按分支独立读取"); prompt.append(el("p", "boundary-warning", "请回到会话卡,选择具体分支文件后再查看摘要和原文。面板不会猜测默认分支。")); root.append(wrap); return; }
320
381
  const comps = section(wrap, "摘要分段", "层级为审阅展示,不是存储字段");
321
382
  if (state.detailErrors?.compartments) errorBox(comps, state.detailErrors.compartments);
@@ -338,14 +399,15 @@
338
399
  const bounds = c.bounds || {}; const boundRow = el("div", "bound-row"); boundRow.append(el("span", "", "边界")); for (const [side, label] of [["start", "起点"], ["end", "终点"]]) {
339
400
  const bound = bounds[side] || { state: "unresolved" }; const stateName = bound.state === "synth-resolved" ? "经别名解析" : bound.state === "resolved" ? "已关联" : bound.state === "unlinked" ? "无边界 id" : "边界无法解析";
340
401
  boundRow.append(el("span", "badge", `${label} · ${stateName}`));
341
- if (bound.state === "resolved" || bound.state === "synth-resolved") { const jump = btn(`跳到${label} #${bound.anchor}`, "jump", "link-button"); jump.dataset.anchor = bound.anchor; boundRow.append(jump); if (bound.aliasedTo) boundRow.append(el("span", "section-note", `→ ${bound.aliasedTo}`)); }
402
+ if (bound.state === "resolved" || bound.state === "synth-resolved") { boundRow.append(el("span", "bound-coordinates", coordinateText(bound.coordinates))); const jump = btn(`跳到${label}`, "jump", "link-button"); jump.dataset.anchor = bound.anchor; boundRow.append(jump); if (bound.aliasedTo) boundRow.append(el("span", "section-note", `→ ${bound.aliasedTo}`)); } else boundRow.append(el("span", "bound-coordinates", coordinateText(null)));
342
403
  }
343
404
  card.append(boundRow);
344
- const range = compartmentRange(c); if (range) { const view = getSegmentView(c); const raw = el("details", "comp-raw"); raw.dataset.compartmentKey = segmentKey(c); raw.open = view.open; raw.append(el("summary", "link-summary", `展开该段原文 · ${range.start}–${range.end}`)); const body = el("div", "comp-message-panel"); raw.append(body); raw.addEventListener("toggle", () => { view.open = raw.open; if (raw.open) { if (view.skipToggleRequest) view.skipToggleRequest = false; else loadSegmentMessages(c, segmentKey(c), body); } }); card.append(raw); } else card.append(el("p", "boundary-warning", "原文归属:未分组。请在下方“边界未确认的原文”中按需读取。")); parent.append(card);
405
+ const range = compartmentRange(c); if (range) { const view = getSegmentView(c); const raw = el("details", "comp-raw"); raw.dataset.compartmentKey = segmentKey(c); raw.open = view.open; raw.append(el("summary", "link-summary", `展开该段原文 · ${compartmentRangeLabel(c)}`)); const body = el("div", "comp-message-panel"); raw.append(body); raw.addEventListener("toggle", () => { view.open = raw.open; if (raw.open) { if (view.skipToggleRequest) view.skipToggleRequest = false; else loadSegmentMessages(c, segmentKey(c), body); } }); card.append(raw); } else card.append(el("p", "boundary-warning", "原文归属:未分组。请在下方“边界未确认的原文”中按需读取。")); parent.append(card);
345
406
  }
346
407
  function segmentKey(c) { return String(c.id ?? `sequence-${c.sequence}`); }
347
408
  function numericBoundary(bound) { return (bound?.state === "resolved" || bound?.state === "synth-resolved") && Number.isSafeInteger(bound.anchor) ? bound.anchor : null; }
348
409
  function compartmentRange(c) { const start = numericBoundary(c.bounds?.start); const end = numericBoundary(c.bounds?.end); return start !== null && end !== null && start <= end ? { start, end } : null; }
410
+ function compartmentRangeLabel(c) { const start = c.bounds?.start?.coordinates; const end = c.bounds?.end?.coordinates; if (start?.hostSeq != null && end?.hostSeq != null) return `宿主序 ${start.hostSeq}–${end.hostSeq}`; if (start?.rawOrdinal != null && end?.rawOrdinal != null) return `原文序 ${start.rawOrdinal}–${end.rawOrdinal}`; return "坐标未知"; }
349
411
  function boundaryReason(c) { const bounds = c.bounds || {}; return ["start", "end"].map((side) => { const value = bounds[side]?.state; return `${side === "start" ? "起点" : "终点"}:${value === "unlinked" ? "无边界 id" : value === "synth-resolved" || value === "resolved" ? "数字边界无效" : "边界无法解析"}`; }).join(";"); }
350
412
  function getSegmentView(c) { const key = segmentKey(c); let view = state.segmentViews.get(key); if (!view) { const range = compartmentRange(c); view = { open: false, after: range ? range.start - 1 : null, cursors: [], page: null, loading: false, error: null, fullOutputs: false, openToolOutputs: new Set(), request: 0 }; state.segmentViews.set(key, view); } return view; }
351
413
  function renderUngroupedSection(parent, title, note, reason, initialAfter = null) {
@@ -359,7 +421,7 @@
359
421
  const role = row.role === "user" ? "用户" : row.role === "assistant" ? "助手" : row.role || "未知角色";
360
422
  head.append(el("strong", "message-role", role));
361
423
  const identity = el("span", "message-aux");
362
- identity.append(el("span", "message-index", `消息 #${row.key}`));
424
+ identity.append(el("span", "message-index", coordinateText(row.coordinates)));
363
425
  const messageId = String(row.id || "无 ID"); const idLabel = messageId.length > 32 ? `${messageId.slice(0, 15)}…${messageId.slice(-10)}` : messageId;
364
426
  const id = el("span", "message-id", idLabel); id.title = `消息 ID:${messageId}`; identity.append(id); head.append(identity, el("time", "message-time", formatTime(row.timeCreated))); card.append(head);
365
427
  for (const [index, part] of (row.parts || []).entries()) renderPart(card, part, row, index, scope);
@@ -421,9 +483,9 @@
421
483
  const unknown = el("details", "unknown-part"); unknown.append(el("summary", "item-sub", `其他内容 · ${part?.type || "未知类型"}`)); expandableText(unknown, JSON.stringify(part, null, 2), "tool-output", "pre"); parent.append(unknown);
422
484
  }
423
485
  async function openMemory(memory) {
424
- state.memory = memory; state.session = null; renderItems(); setLevel("detail"); showDetailLoading("读取记忆详情与证据…");
425
- try { const data = await api(path(state.project.projectPath, `/memories/${encodeURIComponent(memory.id)}`)); state.memoryDetail = data; renderMemoryDetail(); }
426
- catch (error) { $("#detail").replaceChildren(); errorBox($("#detail"), error); }
486
+ const previousMemory = state.memory; const previousDetail = state.memoryDetail; state.memory = memory; state.session = null; saveViewState(); renderItems(); setLevel("detail"); showDetailLoading("读取记忆详情与证据…");
487
+ try { const data = await api(path(state.project.projectPath, `/memories/${encodeURIComponent(memory.id)}`)); state.memoryDetail = data; renderMemoryDetail(); }
488
+ catch (error) { if (previousMemory && previousDetail) { state.memory = previousMemory; state.memoryDetail = previousDetail; renderMemoryDetail(); toast(`详情读取失败,仍显示旧记忆:${error.message}`, 6000); } else { $("#detail").replaceChildren(); errorBox($("#detail"), error); } }
427
489
  }
428
490
  function kv(parent, label, value) { const item = el("div", "kv"); item.append(el("span", "kv-label", label), el("span", "kv-value", value == null || value === "" ? "—" : String(value))); parent.append(item); }
429
491
  function renderMemoryDetail() {
@@ -469,14 +531,15 @@
469
531
  async function refresh() {
470
532
  const button = $("#refresh"); button.disabled = true; button.textContent = "刷新中…";
471
533
  try {
472
- const selectedPath = state.project?.projectPath; const sessionId = state.session?.sessionId; const selectedMemoryId = state.memory?.id; const branch = state.branch;
534
+ const selectedPath = state.project?.projectPath; const selectedSessionKey = state.session ? sessionKey(state.session) : null; const selectedMemoryId = state.memory?.id; const branch = state.branch; const scrollTop = $("#items")?.scrollTop || 0; saveViewState();
473
535
  await loadProjects(false);
474
536
  state.project = state.projects.find((item) => item.projectPath === selectedPath) || null; renderProjects();
475
537
  if (!state.project) { state.session = state.memory = null; showNoSelection(); }
476
538
  else {
477
539
  await loadCurrent();
478
- if (state.tab === "sessions" && sessionId) { const current = state.sessions.find((item) => item.sessionId === sessionId); if (current) await openSession(current, branch); else { state.session = null; showNoSelection(); } }
479
- if (state.tab === "memories" && selectedMemoryId != null) { const current = state.memories.find((item) => item.id === selectedMemoryId); if (current) await openMemory(current); else { state.memory = null; showNoSelection(); } }
540
+ if (state.tab === "sessions" && selectedSessionKey) { const current = state.sessions.find((item) => sessionKey(item) === selectedSessionKey); if (current) await openSession(current, branch); else if (!state.session) showNoSelection(); }
541
+ if (state.tab === "memories" && selectedMemoryId != null) { const current = state.memories.find((item) => item.id === selectedMemoryId); if (current) await openMemory(current); else if (state.memoryDetail) renderMemoryDetail(); else showNoSelection(); }
542
+ requestAnimationFrame(() => { $("#items").scrollTop = scrollTop; saveViewState(); });
480
543
  }
481
544
  toast("已重新读取当前视图");
482
545
  } finally { button.disabled = false; button.textContent = "刷新数据"; }
@@ -484,9 +547,11 @@
484
547
  document.addEventListener("click", async (event) => {
485
548
  const target = event.target.closest("[data-action]"); if (!target) return; const action = target.dataset.action;
486
549
  try {
487
- if (action === "toggle-preview") { setPreviewExpanded(target, target.getAttribute("aria-expanded") !== "true"); return; }
550
+ if (action === "toggle-preview") { setPreviewExpanded(target, target.getAttribute("aria-expanded") !== "true"); return; }
488
551
  if (action === "project") await loadProject(state.projects.find((p) => p.projectPath === target.dataset.project));
489
- else if (action === "session") await openSession(state.sessions.find((s) => s.sessionId === target.dataset.sid), null);
552
+ else if (action === "session") await openSession(state.sessions.find((s) => sessionKey(s) === `${target.dataset.harness || "unknown"}:${target.dataset.sid}`), null);
553
+ else if (action === "toggle-session") { const key = target.dataset.sessionKey; if (state.expandedSessionIds.has(key)) state.expandedSessionIds.delete(key); else state.expandedSessionIds.add(key); saveViewState(); renderItems(); }
554
+ else if (action === "toggle-orphans") { state.orphanExpanded = !state.orphanExpanded; saveViewState(); renderItems(); }
490
555
  else if (action === "branch") { const session = state.sessions.find((s) => s.sessionId === target.dataset.sid); event.stopPropagation(); await openSession(session, target.dataset.branch); }
491
556
  else if (action === "memory") await openMemory(state.memories.find((m) => m.id === Number(target.dataset.mid)));
492
557
  else if (action === "jump") await jumpTo(target.dataset.anchor);
@@ -509,7 +574,13 @@
509
574
  const raw = target.closest(".comp-raw,.ungrouped-raw"); const compKey = raw?.dataset.compartmentKey; const compartment = state.compartments.find((item) => segmentKey(item) === compKey); const view = compartment ? getSegmentView(compartment) : state.ungroupedView; const openTools = view.openToolOutputs; const open = openTools.has(key); if (open) { openTools.delete(key); setToolBlockOpen(block, false); }
510
575
  else { openTools.add(key); if (target.dataset.requiresFullOutput === "true") { if (compartment) await loadFullOutputs(key, { view, compartment, key: compKey, parent: $(".comp-message-panel", raw) }); else if (raw) await loadRawFallback($(".comp-message-panel", raw), null, true, key); else setToolBlockOpen(block, true); } else setToolBlockOpen(block, true); }
511
576
  }
512
- else if (action === "search-memories") await loadMemories();
577
+ else if (action === "search-memories") await loadMemories(true);
578
+ else if (action === "load-more-memories") await loadMemories(false);
579
+ else if (action === "clear-session-filters") { state.sessionTitleQuery = ""; state.sessionHarness = ""; state.sessionSince = ""; state.sessionUntil = ""; onSessionFilterChange(); }
580
+ else if (action === "clear-memory-filters") { state.memoryQuery = ""; state.memoryStatus = "unarchived"; state.memoryCategories = []; state.memorySort = "updated-desc"; renderListControls(); await loadMemories(true); }
581
+ else if (action === "retry-sessions") await loadSessions();
582
+ else if (action === "retry-memories") await loadMemories(true);
583
+ else if (action === "retry-projects") await loadProjects(true);
513
584
  else if (action === "mem-save") await writeMemory("edit");
514
585
  else if (action === "mem-archive") { if (confirm("归档这条记忆?(可在详情页恢复)")) await writeMemory("archive"); }
515
586
  else if (action === "mem-restore") await writeMemory("restore");
@@ -517,7 +588,7 @@
517
588
  else if (action === "back-list") setLevel("list"); else if (action === "back-projects") setLevel("projects");
518
589
  } catch (error) { toast(error.message); }
519
590
  });
520
- $("#sessions-tab").addEventListener("click", () => tabTo("sessions")); $("#memories-tab").addEventListener("click", () => tabTo("memories")); $("#refresh").addEventListener("click", refresh);
521
- document.addEventListener("keydown", (event) => { const card = event.target?.matches?.('[data-action="session"]') ? event.target : null; if (card && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); card.click(); return; } if (event.key === "Escape" && document.body.dataset.level === "detail") setLevel("list"); });
522
- setLevel("projects"); loadProjects(false);
591
+ $("#sessions-tab").addEventListener("click", () => tabTo("sessions")); $("#memories-tab").addEventListener("click", () => tabTo("memories")); $("#refresh").addEventListener("click", refresh); $("#items").addEventListener("scroll", () => saveViewState(), { passive: true });
592
+ document.addEventListener("keydown", (event) => { const target = event.target; if ((event.key === "Enter" || event.key === " ") && target?.matches?.('[data-action="session"], [data-action="memory"]')) { event.preventDefault(); target.click(); return; } const treeNode = target?.matches?.(".session-tree-node") ? target : null; if (treeNode && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); const session = state.sessions.find((item) => sessionKey(item) === treeNode.dataset.sessionKey); if (session) openSession(session); return; } if (event.key === "Escape" && document.body.dataset.level === "detail") setLevel("list"); });
593
+ readViewState(); $("#sessions-tab").classList.toggle("active", state.tab === "sessions"); $("#sessions-tab").setAttribute("aria-selected", String(state.tab === "sessions")); $("#memories-tab").classList.toggle("active", state.tab === "memories"); $("#memories-tab").setAttribute("aria-selected", String(state.tab === "memories")); setLevel("projects"); loadProjects(false);
523
594
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retaw106/mmc-pi",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "Pi and OMP coding agent extension for Magic Context — cross-session memory and context management",
6
6
  "main": "dist/index.js",