pi-skill-evolve 0.1.5 → 0.1.6

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.
Files changed (25) hide show
  1. package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
  2. package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
  3. package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
  4. package/extensions/skill-evolve/lib/web/render-rows.ts +177 -235
  5. package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
  6. package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
  7. package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
  8. package/extensions/skill-evolve/lib/web/render.ts +116 -54
  9. package/extensions/skill-evolve/lib/web/server.ts +9 -1
  10. package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
  11. package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
  12. package/extensions/skill-evolve/lib/web/shell.ts +42 -11
  13. package/extensions/skill-evolve/lib/web/style.ts +171 -106
  14. package/extensions/skill-evolve/lib/web/text.ts +57 -0
  15. package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
  16. package/extensions/skill-evolve/test/session-page.test.ts +11 -5
  17. package/extensions/skill-evolve/test/session-render.test.ts +203 -0
  18. package/extensions/skill-evolve/test/session-view.test.ts +75 -87
  19. package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
  20. package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
  21. package/extensions/skill-evolve/test/web-render.test.ts +57 -41
  22. package/extensions/skill-evolve/test/web-server.test.ts +4 -1
  23. package/extensions/skill-evolve/test/web-session-column.test.ts +30 -32
  24. package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
  25. package/package.json +1 -1
@@ -0,0 +1,264 @@
1
+ /**
2
+ * 列表页重做的验收测试(node --test):分组、表格五列、时间与提案格式、徽章、空态、分页,
3
+ * 以及用户现场报过的「待处理一直帮我收起」(默认不自动刷新、分组默认展开、`open=none` 才收起)。
4
+ *
5
+ * 纪律:全部走 `renderPage` 的纯字符串渲染 + 本文件现造的 state,不读真实 `.pi/evolution`。
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+
10
+ import type { DoneItem, PendingItem, WebState } from "../lib/web/data.ts";
11
+ import { renderPage } from "../lib/web/render.ts";
12
+ import { STYLE } from "../lib/web/style.ts";
13
+ import { formatClock, formatIso } from "../lib/web/text.ts";
14
+
15
+ const NOW = new Date(2026, 10, 8, 21, 57, 3).getTime();
16
+ const T_LATE = new Date(2026, 10, 8, 19, 35).getTime();
17
+ const T_EARLY = new Date(2026, 10, 7, 18, 24).getTime();
18
+ const SID = "01a1b90f-08c3-722c-871d-d024b083770a";
19
+ const SESSION = { sessionId: SID, sessionFile: "/tmp/demo.jsonl" };
20
+ const NO_SESSION = { sessionId: null, sessionFile: null };
21
+
22
+ function emptyState(): WebState {
23
+ return {
24
+ pending: [],
25
+ approvals: [],
26
+ applied: [],
27
+ done: [],
28
+ reports: [],
29
+ summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
30
+ };
31
+ }
32
+
33
+ function pending(runId: string, at: number, parts: Partial<PendingItem> = {}): PendingItem {
34
+ return { runId, kind: "approval", session: SESSION, skill: "demo", at, detail: "", ...parts };
35
+ }
36
+
37
+ function doneItem(runId: string, status: string, at: number): DoneItem {
38
+ return { runId, status, source: "run", level: "-", skill: "demo", at, detail: "", session: NO_SESSION };
39
+ }
40
+
41
+ function withPending(items: PendingItem[]): WebState {
42
+ const state = emptyState();
43
+ state.pending = items;
44
+ return state;
45
+ }
46
+
47
+ test("分组:同一会话的多条 run 合成一个 <details open>,标题写「条数 · 最近 · 几条要处理」", () => {
48
+ const html = renderPage(withPending([pending("run-1", T_EARLY), pending("run-2", T_LATE)]), "pending", { now: NOW });
49
+ assert.match(html, /<details class="grp" open>\n<summary><a class="sess" [^>]*>会话 01a1b90f<\/a> · 2 条 · 最近 19:35 · 2 条需要处理<\/summary>/);
50
+ });
51
+
52
+ test("分组标题:全部已处理时写「无待办」,不写「0 条需要处理」", () => {
53
+ const html = renderPage(
54
+ withPending([pending("run-1", T_EARLY, { kind: "reviewed" }), pending("run-2", T_LATE, { kind: "reviewed" })]),
55
+ "pending",
56
+ { now: NOW },
57
+ );
58
+ assert.match(html, /· 2 条 · 最近 19:35 · 无待办<\/summary>/);
59
+ });
60
+
61
+ test("单条会话的 run 不进组:直接一行表格,会话名写在状态格里", () => {
62
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
63
+ assert.equal(html.includes('<details class="grp"'), false, "一条没必要折叠");
64
+ assert.match(html, /<span class="sk-sess"><a class="sess" href="[^"]+" title="01a1b90f[^"]*">会话 01a1b90f<\/a><\/span>/);
65
+ });
66
+
67
+ test("操作列只写「详情 · 日志」,runId 只在链接 title 与详情页里", () => {
68
+ const runId = "354df7db-1794133942";
69
+ const html = renderPage(withPending([pending(runId, T_LATE)]), "pending", { now: NOW });
70
+ assert.match(html, new RegExp(`<a class="rid" href="/\\?view=run&amp;runId=${runId}" title="${runId}">详情</a>`));
71
+ // 「详情 …94133942」这种半截 runId 不再出现在页面上(只读面板没有重跑,操作就是 详情 · 日志)
72
+ assert.equal(html.includes(`…${runId.slice(-8)}`), false, "操作列不再显示 runId 尾巴");
73
+ const cells = html.slice(html.indexOf('<td class="status">'), html.indexOf('<td class="ops">'));
74
+ assert.equal(cells.includes(runId.slice(0, 8)), false, "runId 前缀不该占着行首");
75
+ });
76
+
77
+ test("操作列文案:只读「详情 · 日志」,可写「详情 · 重跑 · 日志」", () => {
78
+ const readonly = renderPage(withPending([pending("run-ops", T_LATE)]), "pending", { now: NOW });
79
+ assert.match(readonly, /<td class="ops"><a class="rid"[^>]*>详情<\/a> · <a href="\/log\?runId=run-ops">日志<\/a><\/td>/);
80
+ const writable = renderPage(withPending([pending("run-ops", T_LATE)]), "pending", {
81
+ now: NOW,
82
+ canWrite: true,
83
+ csrf: "t",
84
+ });
85
+ assert.match(
86
+ writable,
87
+ /<td class="ops"><a class="rid"[^>]*>详情<\/a> · <form[\s\S]*?value="rerun"[\s\S]*?<\/form> · <a href="\/log\?runId=run-ops">日志<\/a><\/td>/,
88
+ );
89
+ });
90
+
91
+ test("tab 计数是 .cnt 徽章,当前 tab 的计数用 --blue 底", () => {
92
+ const html = renderPage(withPending([pending("run-1", T_LATE), pending("run-2", T_EARLY)]), "pending", { now: NOW });
93
+ assert.match(html, /<a href="\/\?view=pending" class="active">待处理<span class="cnt">2<\/span><\/a>/);
94
+ assert.match(html, /<a href="\/\?view=done">已处理<span class="cnt">0<\/span><\/a>/);
95
+ assert.match(html, /\.tabs \.cnt \{[^}]*border-radius: 999px[^}]*\}/);
96
+ assert.match(html, /\.tabs a\.active \.cnt \{ background: var\(--blue\)/);
97
+ });
98
+
99
+ test("时间:今天写 HH:mm,更早写 MM-DD HH:mm,title 一律是完整 ISO", () => {
100
+ const state = emptyState();
101
+ state.done = [doneItem("run-today", "applied", T_LATE), doneItem("run-old", "applied", T_EARLY)];
102
+ const html = renderPage(state, "done", { now: NOW });
103
+ const today = formatClock(T_LATE, NOW);
104
+ const early = formatClock(T_EARLY, NOW);
105
+ assert.equal(today.length, 5, "今天只给时分");
106
+ assert.match(today, /^\d\d:\d\d$/);
107
+ assert.match(early, /^\d\d-\d\d \d\d:\d\d$/);
108
+ assert.match(html, new RegExp(`<td class="time" title="${formatIso(T_LATE)}">${today}</td>`));
109
+ assert.match(html, new RegExp(`<td class="time" title="${formatIso(T_EARLY)}">${early}</td>`));
110
+ });
111
+
112
+ test("提案列:有增删写「+3 / -1」,没有就写占位符(不是空白)", () => {
113
+ const state = emptyState();
114
+ state.applied = [
115
+ {
116
+ runId: "run-a",
117
+ session: NO_SESSION,
118
+ level: "auto",
119
+ skill: "demo",
120
+ type: "improve",
121
+ added: 3,
122
+ removed: 1,
123
+ appliedAt: T_LATE,
124
+ gates: [],
125
+ gatesOk: null,
126
+ },
127
+ ];
128
+ assert.match(renderPage(state, "applied", { now: NOW }), /<td class="delta">\+3 \/ -1<\/td>/);
129
+ // 待处理行没有提案数字(还没生成或还没采纳)
130
+ assert.match(renderPage(withPending([pending("run-p", T_LATE)]), "pending", { now: NOW }), /<td class="delta"><span class="off">—<\/span><\/td>/);
131
+ });
132
+
133
+ test("徽章:七种状态各一色(待入队灰 / 待审批蓝 / 复盘中黄 / 已采纳绿 / 卡死红 / 已拒绝暗红 / 已回滚淡)", () => {
134
+ const pendingCase = (kind: PendingItem["kind"], detail: string): string =>
135
+ renderPage(withPending([pending("r1", T_LATE, { kind, detail })]), "pending", { now: NOW });
136
+ const doneCase = (status: string): string => {
137
+ const state = emptyState();
138
+ state.done = [doneItem("r1", status, T_LATE)];
139
+ return renderPage(state, "done", { now: NOW });
140
+ };
141
+ assert.match(pendingCase("reviewed", "详情"), /<span class="badge gray" title="详情">待入队<\/span>/);
142
+ assert.match(pendingCase("approval", "详情"), /<span class="badge blue" title="详情">待审批<\/span>/);
143
+ assert.match(pendingCase("running", "阶段:复盘产出中"), /<span class="badge yellow" title="阶段:复盘产出中">复盘中<\/span>/);
144
+ assert.match(pendingCase("stale", "跑了 10 小时"), /<span class="badge red" title="跑了 10 小时">卡死<\/span>/);
145
+ assert.match(doneCase("applied"), /<span class="badge green" title="">已采纳<\/span>/);
146
+ assert.match(doneCase("rejected"), /<span class="badge darkred" title="">已拒绝<\/span>/);
147
+ assert.match(doneCase("rolledback"), /<span class="badge faint" title="">已回滚<\/span>/);
148
+ assert.match(doneCase("failed"), /<span class="badge red" title="">失败<\/span>/);
149
+ });
150
+
151
+ test("表格:五列固定表头,行是 <tr> 而不是卡片", () => {
152
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
153
+ assert.match(html, /<table class="rows">/);
154
+ assert.match(html, /<thead><tr><th class="status">状态<\/th><th class="time">时间<\/th><th class="skill">技能<\/th><th class="delta">提案<\/th><th class="ops">操作<\/th><\/tr><\/thead>/);
155
+ assert.match(html, /<th class="ops">操作<\/th>/);
156
+ });
157
+
158
+ test("操作列:只读实例给「详情 · 日志」两个链接(不再是每行两个大按钮)", () => {
159
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
160
+ assert.match(html, /<\/a> · <a href="[^"]*">日志<\/a>/);
161
+ assert.equal(html.includes('name="name" value="rerun"'), false, "只读实例不该出现重跑表单");
162
+ });
163
+
164
+ test("操作列:可写实例才给「重跑」,卡死的才给「清理」", () => {
165
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", {
166
+ now: NOW,
167
+ canWrite: true,
168
+ csrf: "tok",
169
+ });
170
+ assert.match(html, /name="name" value="rerun"/);
171
+ assert.equal(html.includes('value="cleanup"'), false, "没卡死就不给清理");
172
+
173
+ const staleHtml = renderPage(withPending([pending("run-1", T_LATE, { kind: "stale", detail: "跑了 10 小时" })]), "pending", {
174
+ now: NOW,
175
+ canWrite: true,
176
+ csrf: "tok",
177
+ });
178
+ assert.match(staleHtml, /name="name" value="cleanup"/);
179
+ });
180
+
181
+ test("空态:一句话说清为什么空,再给一句下一步(带具体命令)", () => {
182
+ const html = renderPage(emptyState(), "pending", { now: NOW });
183
+ assert.match(html, /<p class="empty">暂无待处理:所有复盘都已完成,也没有待人批的改动。<\/p>/);
184
+ assert.match(html, /<p class="next">.*\/skill-evolve dispatch on/);
185
+ });
186
+
187
+ test("分页:写「第 1 / 2 页 · 共 23 条」,下一页链接保留筛选参数", () => {
188
+ const items = Array.from({ length: 23 }, (_, index) => pending(`run-${index}`, T_LATE - index * 1000));
189
+ const state = withPending(items);
190
+ const filter = { skill: "demo", status: "", range: "7d" as const, page: 1 };
191
+ const html = renderPage(state, "pending", { now: NOW, filter });
192
+ assert.match(html, /<p class="page"><span class="off">← 上一页<\/span> <span class="count">第 1 \/ 2 页 · 共 23 条<\/span> <a href="[^"]*">下一页 →<\/a><\/p>/);
193
+ const next = /<a href="([^"]*)">下一页 →<\/a>/.exec(html)?.[1] ?? "";
194
+ assert.ok(next.includes("skill=demo"), "技能筛选要留住");
195
+ assert.ok(next.includes("range=7d"), "时间范围要留住");
196
+ assert.ok(next.includes("page=2"), "翻到第 2 页");
197
+ });
198
+
199
+ test("默认不自动刷新:没有 meta refresh,只有「更新于 … · 刷新」", () => {
200
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
201
+ assert.equal(html.includes('http-equiv="refresh"'), false, "默认整页重载会把展开态清掉");
202
+ assert.match(html, /更新于 21:57:03 · <a href="[^"]+">刷新<\/a>/);
203
+ });
204
+
205
+ test("&live=1 才 5 秒重载,并在页头写清代价 + 给「关掉」", () => {
206
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", {
207
+ now: NOW,
208
+ extras: { live: true, open: "all", only: null },
209
+ });
210
+ assert.match(html, /<meta http-equiv="refresh" content="5; url=\/\?view=pending/);
211
+ assert.match(html, /自动刷新中(每 5 秒整页重载,展开态会重置)· <a href="[^"]*">关掉<\/a>/);
212
+ const off = /自动刷新中[^<]*· <a href="([^"]*)">关掉<\/a>/.exec(html)?.[1] ?? "";
213
+ assert.equal(off.includes("live=1"), false, "关掉链接不能还带着 live=1");
214
+ });
215
+
216
+ test("live=1 只对待处理页生效:其它视图给了也忽略(数据不会自己变)", () => {
217
+ const html = renderPage(emptyState(), "done", { now: NOW, extras: { live: true, open: "all", only: null } });
218
+ assert.equal(html.includes('http-equiv="refresh"'), false);
219
+ assert.equal(html.includes("自动刷新中"), false);
220
+ });
221
+
222
+ test("open=none 时分组不带 open 属性(用户点过「收起」,就靠 URL 记住)", () => {
223
+ const items = [pending("run-1", T_EARLY), pending("run-2", T_LATE)];
224
+ const open = renderPage(withPending(items), "pending", { now: NOW });
225
+ assert.match(open, /<details class="grp" open>/);
226
+ const closed = renderPage(withPending(items), "pending", { now: NOW, extras: { live: false, open: "none", only: null } });
227
+ assert.match(closed, /<details class="grp">/);
228
+ assert.equal(closed.includes('<details class="grp" open>'), false);
229
+ });
230
+
231
+ test("刷新链接保留原始 query:页码、时间范围、技能筛选一个都不丢", () => {
232
+ const items = Array.from({ length: 23 }, (_, index) => pending(`run-${index}`, T_LATE - index * 1000));
233
+ const filter = { skill: "demo", status: "", range: "30d" as const, page: 2 };
234
+ const html = renderPage(withPending(items), "pending", { now: NOW, filter });
235
+ const refresh = /更新于 [^·]*· <a href="([^"]+)">刷新<\/a>/.exec(html)?.[1] ?? "";
236
+ assert.ok(refresh.includes("page=2"), "刷新不能把人弹回第 1 页");
237
+ assert.ok(refresh.includes("range=30d"));
238
+ assert.ok(refresh.includes("skill=demo"));
239
+ });
240
+
241
+ test("分组开关:有组才给「收起全部分组」,没有组不给点了没反应的链接", () => {
242
+ const grouped = renderPage(withPending([pending("run-1", T_EARLY), pending("run-2", T_LATE)]), "pending", { now: NOW });
243
+ assert.match(grouped, /<a href="[^"]*open=none">收起全部分组<\/a>/);
244
+ const single = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
245
+ assert.equal(single.includes("收起全部分组"), false);
246
+ });
247
+
248
+ test("顶部粘性区:摘要行四个数字 + 每个 tab 带计数徽章", () => {
249
+ const state = withPending([pending("run-1", T_LATE), pending("run-2", T_EARLY)]);
250
+ state.summary = { reviewing: 2, stale: 1, pendingApprovals: 0, appliedTotal: 5, rollbackRate: 0.2 };
251
+ const html = renderPage(state, "pending", { now: NOW });
252
+ assert.match(html, /<p class="sum">复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">0<\/a> · 已采纳 <a href="\/\?view=applied">5<\/a>/);
253
+ assert.match(html, /<a href="\/\?view=pending" class="active">待处理<span class="cnt">2<\/span><\/a>/);
254
+ });
255
+
256
+ test("窄屏靠 CSS 让位:≤900px 隐藏时间/提案列,宽屏才给左侧目录", () => {
257
+ assert.match(STYLE, /\.hd \{ position: sticky; top: 0/);
258
+ assert.ok(STYLE.includes("@media (max-width: 900px)"), "要有窄屏断点");
259
+ assert.ok(STYLE.includes(".rows .time, .rows .delta { display: none }"), "窄屏让时间/提案列让位");
260
+ assert.match(STYLE, /@media \(min-width: 1200px\)/);
261
+ const html = renderPage(emptyState(), "pending", { now: NOW });
262
+ assert.match(html, /<meta name="viewport" content="width=device-width, initial-scale=1">/);
263
+ assert.match(html, /max-width: 980px/);
264
+ });
@@ -4,10 +4,14 @@ import test from "node:test";
4
4
  import type { WebState } from "../lib/web/data.ts";
5
5
  import { escapeHtml, formatStamp, parseView, renderPage } from "../lib/web/render.ts";
6
6
 
7
+ const NOW = new Date(2026, 10, 8, 12).getTime();
8
+ const NO_SESSION = { sessionId: null, sessionFile: null };
9
+
7
10
  function withApproval(): WebState {
8
11
  const state = emptyState();
9
12
  state.approvals.push({
10
13
  runId: "run-a",
14
+ session: NO_SESSION,
11
15
  skill: "demo",
12
16
  level: "approval",
13
17
  targets: [".pi/skills/demo/SKILL.md"],
@@ -32,7 +36,7 @@ function emptyState(): WebState {
32
36
 
33
37
  function withPending(head: string, detail: string): WebState {
34
38
  const state = emptyState();
35
- state.pending.push({ runId: "run-x", kind: "approval", skill: head, at: 0, detail });
39
+ state.pending.push({ runId: "run-x", kind: "approval", skill: head, at: 0, detail, session: NO_SESSION });
36
40
  return state;
37
41
  }
38
42
 
@@ -49,51 +53,55 @@ test("渲染:技能名与详情里的 <script> 一律转义,页面里没有
49
53
  assert.match(html, /&lt;script&gt;x\(\)&lt;\/script&gt;/);
50
54
  });
51
55
 
52
- test("渲染:findings 里的 HTML 也被转义(报告页同样不可注入)", () => {
56
+ test("渲染:报告页的徽章 title 也被转义(summary 里能塞任意文本)", () => {
53
57
  const state = emptyState();
54
58
  state.reports.push({
55
59
  runId: "run-1",
56
60
  status: "applied",
57
61
  trigger: "auto",
62
+ session: NO_SESSION,
58
63
  startedAt: 0,
59
64
  findings: { total: 1, rows: [{ skill: "<img src=x onerror=alert(1)>", kind: "未加载", reason: "<b>没读</b>" }] },
60
65
  proposal: { type: "improve", skill: "demo", added: 3, removed: 1 },
61
66
  review: { status: "reviewed", verdict: "pass", blocking: 0 },
62
67
  decision: { status: "applied", approver: "auto", at: 0, detail: "<script>d</script>" },
63
68
  verify: { status: "applied", at: 0, gates: [{ gate: "gate0", name: "授权核对", ok: true }] },
69
+ failure: "<img src=x onerror=alert(1)>",
64
70
  });
65
71
  const html = renderPage(state, "reports");
66
- assert.equal(/<img/i.test(html), false);
72
+ assert.equal(/<img/i.test(html), false, "徽章 title 里的标签必须转义");
67
73
  assert.equal(/<script/i.test(html), false);
68
- assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/);
74
+ assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/, "转义后的原文仍在,便于核对");
69
75
  });
70
76
 
71
77
  test("四个视图:标题、active 标签与列表内容各就各位", () => {
72
78
  const state = withPending("demo", "详情");
73
79
  const pending = renderPage(state, "pending");
74
80
  assert.match(pending, /<title>技能进化 · 待处理<\/title>/);
75
- assert.match(pending, /<a href="\/\?view=pending" class="active">待处理<\/a>/);
76
- assert.match(pending, /\[待批\] run-x · demo/);
81
+ assert.match(pending, /<a href="\/\?view=pending" class="active">待处理<span class="cnt">1<\/span><\/a>/);
82
+ // 列表里不再有「[待批] run-x · demo」这种行头:状态是徽章、runId 只在操作列
83
+ assert.match(pending, /<span class="badge blue" title="详情">待审批<\/span>/);
84
+ assert.match(pending, /<td class="skill"><code>demo<\/code><\/td>/);
77
85
 
78
86
  const approvals = renderPage(withApproval(), "approvals");
79
87
  assert.match(approvals, /<title>技能进化 · 待审批<\/title>/);
80
- assert.match(approvals, /<a href="\/\?view=approvals" class="active">待审批<\/a>/);
88
+ assert.match(approvals, /<a href="\/\?view=approvals" class="active">待审批<span class="cnt">1<\/span><\/a>/);
81
89
  assert.equal(approvals.includes("run-x"), false, "待审批页只收 approvals,不串待处理条目");
82
90
 
83
91
  const done = renderPage(state, "done");
84
92
  assert.match(done, /<title>技能进化 · 已处理<\/title>/);
85
- assert.match(done, /<a href="\/\?view=done" class="active">已处理<\/a>/);
86
- assert.equal(done.includes("[待批] run-x"), false, "别的视图不能串内容");
93
+ assert.match(done, /<a href="\/\?view=done" class="active">已处理<span class="cnt">0<\/span><\/a>/);
94
+ assert.equal(done.includes("run-x"), false, "别的视图不能串内容");
87
95
 
88
96
  const reports = renderPage(state, "reports");
89
97
  assert.match(reports, /<title>技能进化 · 复盘报告<\/title>/);
90
- assert.match(reports, /<a href="\/\?view=reports" class="active">复盘报告<\/a>/);
98
+ assert.match(reports, /<a href="\/\?view=reports" class="active">复盘报告<span class="cnt">0<\/span><\/a>/);
91
99
  });
92
100
 
93
101
  test("六个视图:tab 顺序是 待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告 / 技能管理", () => {
94
102
  const html = renderPage(emptyState(), "pending");
95
103
  // 只扫 nav:状态栏与页脚里也有 /?view=approvals 链接,整页扫会把它们算进来
96
- const nav = html.slice(html.indexOf("<nav>"), html.indexOf("</nav>"));
104
+ const nav = html.slice(html.indexOf('<nav class="tabs">'), html.indexOf("</nav>"));
97
105
  const order = [...nav.matchAll(/<a href="\/\?view=(\w+)"/g)].map((match) => match[1]);
98
106
  assert.deepEqual(order, ["pending", "approvals", "applied", "done", "reports", "skills"]);
99
107
  });
@@ -112,13 +120,13 @@ test("空态:每个列表视图各有一句人话", () => {
112
120
  }
113
121
  });
114
122
 
115
- test("状态栏:五个数字按顺序,回滚率取整百分比,待批数字链到待审批页", () => {
123
+ test("摘要行:四个数字按顺序,待批数字链到待审批页(回滚率不在这一行)", () => {
116
124
  const state = emptyState();
117
125
  state.summary = { reviewing: 2, stale: 1, pendingApprovals: 3, appliedTotal: 4, rollbackRate: 0.256 };
118
- const html = renderPage(state, "pending");
126
+ const html = renderPage(state, "pending", { now: new Date(2026, 10, 8, 21, 57, 3).getTime() });
119
127
  assert.match(
120
128
  html,
121
- /复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">3<\/a> · 已采纳 <a href="\/\?view=applied">4<\/a> · 回滚率 26%/,
129
+ /<p class="sum">复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">3<\/a> · 已采纳 <a href="\/\?view=applied">4<\/a> · 更新于 21:57:03 · <a href="[^"]+">刷新<\/a><\/p>/,
122
130
  );
123
131
  });
124
132
 
@@ -128,6 +136,7 @@ test("报告详情:findings 条数、提案增删行、五闸对错都在展
128
136
  runId: "run-2",
129
137
  status: "applied",
130
138
  trigger: "manual",
139
+ session: NO_SESSION,
131
140
  startedAt: 0,
132
141
  findings: { total: 7, rows: [{ skill: "demo", kind: "未加载", reason: "B frontmatter 坏" }] },
133
142
  proposal: { type: "improve", skill: "demo", added: 5, removed: 2 },
@@ -143,19 +152,20 @@ test("报告详情:findings 条数、提案增删行、五闸对错都在展
143
152
  },
144
153
  });
145
154
  const html = renderPage(state, "reports");
146
- assert.match(html, /findings:7 条/);
147
- assert.match(html, /…另有 6 条(见 01-findings\.json)/);
148
- assert.match(html, /提案:improve demo \+5\/-2/);
149
- assert.match(html, /blocking 1 条/);
150
- assert.match(html, /决策:未采纳(没有 04-decision\.json)/);
151
- assert.match(html, /gate0✓ gate3✗/);
155
+ // 列表行只放五个字段:findings / 评审 / 五闸这类细节进徽章 title 与 run 详情页
156
+ assert.match(html, /<span class="badge green" title="[^"]*findings:7 条[^"]*">已采纳<\/span>/);
157
+ assert.match(html, /<td class="skill"><code>demo<\/code><\/td>/);
158
+ assert.match(html, /<td class="delta">\+5 \/ -2<\/td>/);
159
+ assert.match(html, /<a class="rid" href="\/\?view=run&amp;runId=run-2" title="run-2">详情<\/a>/);
152
160
  });
153
161
 
154
- test("页脚写清只读与采纳入口", () => {
162
+ test("页脚是本机面板口径:只绑 127.0.0.1、无鉴权、机器可读副本见 /api/state", () => {
155
163
  const html = renderPage(emptyState(), "pending");
156
- assert.match(html, /只读面板/);
157
- assert.match(html, /\/skill-evolve approve &lt;runId&gt;/);
158
- assert.match(html, /href="\/api\/state"/);
164
+ assert.match(html, /本机面板 · 只绑 127\.0\.0\.1 · 无鉴权/);
165
+ assert.match(html, /机器可读副本见 <a href="\/api\/state">\/api\/state<\/a>/);
166
+ // 面板已能开关 / 重跑 / 清理 / 批准,再说「只读、不能在这里点批准」就是过时文案
167
+ assert.equal(html.includes("只读面板"), false);
168
+ assert.equal(html.includes("不能在这里点批准"), false);
159
169
  });
160
170
 
161
171
  test("parseView:认 approvals / done / reports,其余回落待处理", () => {
@@ -172,18 +182,23 @@ test("formatStamp:null 给破折号,时间按本地 YYYY-MM-DD HH:mm", () =>
172
182
  assert.equal(formatStamp(new Date(2026, 10, 8, 9, 5).getTime()), "2026-11-08 09:05");
173
183
  });
174
184
 
175
- test("待处理页:5 秒自刷新只加在这一页,且刷完还在待处理", () => {
185
+ test("自动刷新默认关闭:只有显式 &live=1 的待处理页才 5 秒重载", () => {
176
186
  const state = emptyState();
177
- assert.match(renderPage(state, "pending"), /<meta http-equiv="refresh" content="5; url=\?view=pending">/);
178
- assert.equal(renderPage(state, "done").includes('http-equiv="refresh"'), false, "看档案时刷新会把页面跳回去");
179
- assert.equal(renderPage(state, "reports").includes('http-equiv="refresh"'), false);
187
+ assert.equal(renderPage(state, "pending").includes('http-equiv="refresh"'), false, "默认不刷新(刷新会丢展开态)");
188
+ assert.match(renderPage(state, "pending", { extras: { live: true, open: "all", only: null } }), /http-equiv="refresh" content="5; url=\/\?view=pending/);
189
+ // live 只对待处理页有意义:别的页给了也忽略
190
+ assert.equal(
191
+ renderPage(state, "done", { extras: { live: true, open: "all", only: null } }).includes('http-equiv="refresh"'),
192
+ false,
193
+ "看档案时刷新会把页面跳回去",
194
+ );
180
195
  });
181
196
 
182
197
  test("待处理页:运行中的条目有自己的徽章,不是只给个数字", () => {
183
198
  const state = emptyState();
184
- state.pending.push({ runId: "run-live", kind: "running", skill: "", at: 0, detail: "阶段:复盘产出中" });
199
+ state.pending.push({ runId: "run-live", kind: "running", skill: "", at: 0, detail: "阶段:复盘产出中", session: NO_SESSION });
185
200
  const html = renderPage(state, "pending");
186
- assert.match(html, /\[进行中\] run-live/);
201
+ assert.match(html, /<span class="badge yellow" title="阶段:复盘产出中">复盘中<\/span>/);
187
202
  });
188
203
 
189
204
  test("复盘报告:卡死的 run 行首标 [卡死],detail 里带失败原因", () => {
@@ -192,6 +207,7 @@ test("复盘报告:卡死的 run 行首标 [卡死],detail 里带失败原
192
207
  runId: "run-dead",
193
208
  status: "stale",
194
209
  trigger: "auto",
210
+ session: NO_SESSION,
195
211
  startedAt: 0,
196
212
  findings: { total: 0, rows: [] },
197
213
  proposal: null,
@@ -200,19 +216,19 @@ test("复盘报告:卡死的 run 行首标 [卡死],detail 里带失败原
200
216
  verify: null,
201
217
  failure: "原因:子进程已退出\n日志末行:Error: 模板缺失",
202
218
  });
203
- const html = renderPage(state, "reports");
204
- assert.match(html, /\[卡死\] <a class="run" href="\/\?view=run&amp;runId=run-dead">run-dead<\/a>/);
219
+ const html = renderPage(state, "reports", { canWrite: true, csrf: "t", now: NOW });
220
+ assert.match(html, /<span class="badge red" title="[^"]*">卡死<\/span>/);
205
221
  assert.match(html, /原因:子进程已退出/);
206
222
  assert.match(html, /日志末行:Error: 模板缺失/);
223
+ // 卡死的 run 才有「清理」(移到 runs/.trash/,不删数据)
224
+ assert.match(html, /name="name" value="cleanup"/);
207
225
  });
208
226
 
209
227
  test("待审批页:条目上是 targets / 提案增删行 / verdict 与 blocking 条数", () => {
210
228
  const html = renderPage(withApproval(), "approvals");
211
- assert.match(html, /\[approval\] run-a · demo · improve \+3\/-1 · pass\/blocking 0/);
212
- assert.match(html, /目标:\.pi\/skills\/demo\/SKILL\.md/);
213
- assert.match(html, /提案:improve \+3\/-1 行/);
214
- assert.match(html, /评审:verdict=pass · blocking 0 条/);
215
- assert.match(html, /runId:run-a/, "详情第一行是完整 runId");
229
+ assert.match(html, /<span class="badge blue" title="approval 级(必须人批) · 目标:\.pi\/skills\/demo\/SKILL\.md · 评审:verdict=pass · blocking 0 条">待审批<\/span>/);
230
+ assert.match(html, /<td class="delta">\+3 \/ -1<\/td>/);
231
+ assert.match(html, /<a class="rid" href="\/\?view=run&amp;runId=run-a" title="run-a">详情<\/a>/);
216
232
  });
217
233
 
218
234
  test("待审批页:注入回调 + csrf 时渲染批准与拒绝表单,csrf 与 runId 都是 hidden", () => {
@@ -244,10 +260,10 @@ test("待审批页脚:批准走同一套五道闸,拒绝不动任何技能
244
260
  assert.match(html, /「拒绝」只改队列状态,不动任何技能文件/);
245
261
  });
246
262
 
247
- test("待处理页脚:保留只读口径,并指向待审批页", () => {
248
- const html = renderPage(emptyState(), "pending");
249
- assert.match(html, /只读面板/);
250
- assert.match(html, /去<a href="\/\?view=approvals">待审批页<\/a>直接批/);
263
+ test("待审批页脚仍然写清「批准」等价于哪条 TUI 命令", () => {
264
+ const html = renderPage(withApproval(), "approvals", { canWrite: true, csrf: "t" });
265
+ assert.match(html, /在 TUI 敲 <code>\/skill-evolve approve &lt;runId&gt;<\/code>/);
266
+ assert.match(html, /机器可读副本:<a href="\/api\/state">/);
251
267
  });
252
268
 
253
269
  test("动作结果横幅:msg 与 err 都渲染且转义", () => {
@@ -58,7 +58,10 @@ test("GET /:真监听 127.0.0.1,返回四个 tab 与队列里的待批条目
58
58
  assert.match(html, /待审批/);
59
59
  assert.match(html, /已处理/);
60
60
  assert.match(html, /复盘报告/);
61
- assert.match(html, /\[待批\] run-web · demo/);
61
+ // 列表页重做后行内不再有「[待批] run-web · demo」:状态是徽章,runId 在操作列
62
+ assert.match(html, /<span class="badge blue"[^>]*>待审批<\/span>/);
63
+ assert.match(html, /<td class="skill"><code>demo<\/code><\/td>/);
64
+ assert.match(html, /<a class="rid" href="\/\?view=run&amp;runId=run-web"[^>]*>详情<\/a>/);
62
65
  } finally {
63
66
  await panel.close();
64
67
  }
@@ -1,27 +1,24 @@
1
1
  /**
2
- * 列表页的「会话」列:五个视图(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)都必须带,
3
- * 且文案一致 —— 只显示 sessionId 前 8 位,完整 id 放 title,点进 `?view=session`。
2
+ * 列表页的「会话」标识:五个视图(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)都必须能看出这条 run 属于哪段对话,
3
+ * 且文案一致 —— 显示 sessionId 前 8 位,完整 id 放 title,点进 `?view=session`。
4
4
  *
5
5
  * 为什么必须每页都有(用户 2026-11-08):「面板展示的也应该是 pi sessionId 否则我都不知道怎么看对应会话」。
6
- * 同一个渲染函数 `sessionCell` 供五处复用,本文件就是把这条约定钉住:
7
- * 少一页就说明有人另写了一份行渲染。
6
+ * 重做列表页后会话不再是一列:同一会话的多条 run 归到组标题(`<details>` 的 summary)里,
7
+ * 单条会话的 run 把会话名写在状态格的小字里 —— 两处都由 render-rows.ts 产出,本文件把这条约定钉住。
8
8
  */
9
9
  import assert from "node:assert/strict";
10
10
  import test from "node:test";
11
11
  import type { AppliedItem } from "../lib/web/applied.ts";
12
12
  import type { ApprovalItem } from "../lib/web/approvals.ts";
13
- import type { QueueItem } from "../lib/queue.ts";
14
13
  import type { DoneItem, PendingItem, WebState } from "../lib/web/data.ts";
15
14
  import type { RunReport } from "../lib/web/reports.ts";
16
15
  import { renderPage } from "../lib/web/render.ts";
17
- import { pendingRows } from "../lib/web/render-rows.ts";
18
16
  import type { SessionRef } from "../lib/web/run-session.ts";
19
17
 
20
18
  const NOW = new Date(2026, 10, 8, 12).getTime();
21
19
  const SESSION_ID = "01a1b90f-08c3-722c-871d-d024b083770a";
22
20
  const SESSION: SessionRef = { sessionId: SESSION_ID, sessionFile: `/tmp/${SESSION_ID}.jsonl` };
23
21
  const NO_SESSION: SessionRef = { sessionId: null, sessionFile: null };
24
- const CTX = { csrf: "c", writable: true, view: "pending" as const };
25
22
 
26
23
  function state(parts: Partial<WebState> = {}): WebState {
27
24
  return {
@@ -89,53 +86,54 @@ function reportItem(session: SessionRef): RunReport {
89
86
 
90
87
  /** 五个视图各塞一条带会话的行,返回整页 HTML */
91
88
  function pageWithSession(): Record<string, string> {
92
- const base: Partial<WebState> = {
89
+ const current = state({
93
90
  pending: [pendingItem(SESSION)],
94
91
  approvals: [approvalItem(SESSION)],
95
92
  applied: [appliedItem(SESSION)],
96
93
  done: [doneItem(SESSION)],
97
94
  reports: [reportItem(SESSION)],
98
- };
99
- const current = state(base);
95
+ });
100
96
  return {
101
- pending: renderPage(current, "pending"),
102
- approvals: renderPage(current, "approvals"),
103
- applied: renderPage(current, "applied"),
104
- done: renderPage(current, "done"),
105
- reports: renderPage(current, "reports"),
97
+ pending: renderPage(current, "pending", { now: NOW }),
98
+ approvals: renderPage(current, "approvals", { now: NOW }),
99
+ applied: renderPage(current, "applied", { now: NOW }),
100
+ done: renderPage(current, "done", { now: NOW }),
101
+ reports: renderPage(current, "reports", { now: NOW }),
106
102
  };
107
103
  }
108
104
 
109
- test("五个视图都有会话列:链接指向 ?view=session,文案是 sessionId 前 8 位,title 是完整 id", () => {
105
+ test("五个视图都能看到会话:链接指向 ?view=session,文案是 sessionId 前 8 位,title 是完整 id", () => {
110
106
  const pages = pageWithSession();
107
+ const href = `/?view=session&amp;sessionId=${SESSION_ID}`;
111
108
  for (const [view, html] of Object.entries(pages)) {
112
- const href = `/?view=session&amp;sessionId=${SESSION_ID}`;
113
109
  assert.equal(html.includes(href), true, `${view} 页缺会话链接`);
114
- assert.match(html, /会话 01a1b90f/, `${view} 页会话列要显示前 8 位`);
110
+ assert.match(html, /会话 01a1b90f/, `${view} 页要显示会话前 8 位`);
115
111
  assert.equal(html.includes(`title="${SESSION_ID}"`), true, `${view} 页 title 要放完整 sessionId`);
116
112
  }
117
113
  });
118
114
 
119
- test("待处理行:会话列跟在 runId 后面,同一行里两者都在", () => {
120
- const html = pendingRows([pendingItem(SESSION)], CTX);
121
- const runIdIndex = html.indexOf("run-p-1794133942");
122
- const sessIndex = html.indexOf('class="sess"');
123
- assert.equal(runIdIndex >= 0 && sessIndex > runIdIndex, true, "会话列应在 runId 之后");
124
- assert.match(html, / · <a class="sess"/, "会话列与行头之间用 · 分隔");
125
- });
126
-
127
- test("待审批行:summary 里同样带会话列(它是自己拼的 details,最容易漏)", () => {
128
- const html = renderPage(state({ approvals: [approvalItem(SESSION)] }), "approvals");
129
- assert.match(html, /<summary class="head">[^<]*<a class="sess" href="\/\?view=session&amp;sessionId=/);
115
+ test("单条会话的 run 不进组:会话名写在状态格的小字里,runId 只出现在操作列", () => {
116
+ const html = renderPage(state({ pending: [pendingItem(SESSION)] }), "pending", { now: NOW });
117
+ assert.match(html, /<span class="sk-sess"><a class="sess"/, "会话名应在状态格的小字里");
118
+ assert.equal(html.includes('<details class="grp"'), false, "只有一条 run 时不该出现会话分组");
119
+ // runId 不再是主标识:只在操作列,显示末 8 位,title 放完整值
120
+ assert.match(html, /<td class="ops">.*title="run-p-1794133942"/);
130
121
  });
131
122
 
132
123
  test("没有 sessionId 的老 run:写「会话 —」占位,不造空链接、不报错", () => {
133
- const html = renderPage(state({ pending: [pendingItem(NO_SESSION)], done: [doneItem(NO_SESSION)] }), "pending");
134
- assert.match(html, /<span class="hint">会话 —<\/span>/);
124
+ const html = renderPage(state({ pending: [pendingItem(NO_SESSION)], done: [doneItem(NO_SESSION)] }), "pending", {
125
+ now: NOW,
126
+ });
127
+ assert.match(html, /<span class="sk-sess">会话 —<\/span>/);
135
128
  assert.equal(html.includes("view=session"), false, "缺 id 时不许出现会话链接");
136
129
  });
137
130
 
138
131
  test("会话链接带 file 参数:页面可直接打开声明过的会话文件,不必再反查", () => {
139
- const html = pendingRows([pendingItem(SESSION)], CTX);
132
+ const html = renderPage(state({ pending: [pendingItem(SESSION)] }), "pending", { now: NOW });
140
133
  assert.match(html, new RegExp(`file=%2Ftmp%2F${SESSION_ID}\\.jsonl`));
141
134
  });
135
+
136
+ test("同一会话的多条 run 归到组标题:会话名出现在 details 的 summary 里", () => {
137
+ const html = renderPage(state({ pending: [pendingItem(SESSION), pendingItem(SESSION)] }), "pending", { now: NOW });
138
+ assert.match(html, /<details class="grp" open>\n<summary><a class="sess" href="\/\?view=session&amp;sessionId=/);
139
+ });