pi-skill-evolve 0.1.5 → 0.1.7

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 (28) hide show
  1. package/extensions/skill-evolve/lib/runs.ts +4 -1
  2. package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
  3. package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
  4. package/extensions/skill-evolve/lib/web/pending.ts +5 -5
  5. package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
  6. package/extensions/skill-evolve/lib/web/render-rows.ts +178 -235
  7. package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
  8. package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
  9. package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
  10. package/extensions/skill-evolve/lib/web/render.ts +116 -54
  11. package/extensions/skill-evolve/lib/web/server.ts +9 -1
  12. package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
  13. package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
  14. package/extensions/skill-evolve/lib/web/shell.ts +42 -11
  15. package/extensions/skill-evolve/lib/web/style.ts +171 -106
  16. package/extensions/skill-evolve/lib/web/text.ts +57 -0
  17. package/extensions/skill-evolve/test/pending-kinds.test.ts +142 -0
  18. package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
  19. package/extensions/skill-evolve/test/session-page.test.ts +11 -5
  20. package/extensions/skill-evolve/test/session-render.test.ts +203 -0
  21. package/extensions/skill-evolve/test/session-view.test.ts +75 -87
  22. package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
  23. package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
  24. package/extensions/skill-evolve/test/web-render.test.ts +57 -41
  25. package/extensions/skill-evolve/test/web-server.test.ts +4 -1
  26. package/extensions/skill-evolve/test/web-session-column.test.ts +30 -32
  27. package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
  28. package/package.json +1 -1
@@ -1,142 +1,146 @@
1
1
  /**
2
- * 列表行的渲染:五种视图(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)各一段,加上每行针对那条 run 的操作按钮。
2
+ * 列表行的渲染:五种视图(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)先各自映射成同一份 `ListRow`,
3
+ * 再由这里的表格与分组渲染成 HTML。
3
4
  *
4
- * 为什么单独一个文件:行渲染是面板里最长的一段(抬头 + 详情 + 行内表单),
5
- * 和页面骨架(状态栏 / 导航 / 页脚)混在一起必然破 300 行。
5
+ * 为什么分两步:五个视图的数据字段不同(提议数在 applied、verdict 在 approvals、findings 在 reports),
6
+ * 但「一行长什么样」必须完全一致 —— 否则同一个人在两页看到的徽章颜色与操作位置都不一样。
7
+ * 映射(本文件的 `xxxRows`)负责取数与措辞,渲染(./render-table.ts)负责表格、分组。
8
+ * 拆两个文件是因为「取数」与「画表」各自都会长;合在一起必然破 300 行。
6
9
  *
7
- * 行的操作只有三个(重跑 / 清理 / 看日志),且**只在待处理与复盘报告页**出现:
8
- * 待审批页逐行就是批准/拒绝,已采纳与已处理页是档案(只给看 diff / 看日志),都不挂「重跑」——
9
- * 一个已采纳的 run 旁边放「重跑」按钮,只会让人以为它能重放整个采纳流程。
10
- *
11
- * 按钮只在 `writable` 且拿到 csrf 时渲染,绝不自己造一个「能点但写不动」的按钮;
12
- * 「看日志」是只读 GET,只读面板也照常给(它不写任何东西)。
10
+ * 三条取舍:
11
+ * 1. **不再把 runId 当主标识**:真实数据里同一个会话的 9 条 run 前 8 位完全一样(`354df7db…`),
12
+ * 拿它当抬头等于每行都在报同一串字符。runId 只留在操作列里,显示末 8 位、`title` 放完整值。
13
+ * 2. **操作是小号链接,不是每行两个大按钮**:一行一条 run,按钮比信息显眼就本末倒置了。
14
+ * 3. **同一会话的多条 run 收进一个 `<details>`**:9 条 run 逐行重复「重跑 / 日志」正是原来列表被压没的原因。
13
15
  */
14
16
  import type { AppliedItem, ApprovalItem, DoneItem, PendingItem, RunReport, WebView } from "./data.ts";
15
17
  import type { SessionRef } from "./run-session.ts";
16
- import { sessionHref } from "./run-session.ts";
17
- import { escapeHtml, formatStamp, shortRunId, withId } from "./text.ts";
18
+ import { escapeHtml } from "./text.ts";
18
19
 
19
20
  /** 行内操作需要的上下文:csrf 与 canWrite 决定按钮渲不渲染,view 决定动作做完回哪一页 */
20
21
  export interface RowContext {
21
22
  csrf: string;
22
23
  writable: boolean;
23
24
  view: WebView;
24
- }
25
-
26
- /**
27
- * 抬头里的 runId 做成链接(点进去看 run 详情页:diff / 评审 / 五闸)。
28
- *
29
- * 实现上**先整段转义、再把 shortRunId 那一段换成锚点**:抬头文本来自磁盘,
30
- * 绝不能先拼 HTML 再转义(会出现两次转义与注入两种毛病)。
31
- */
32
- function linkRunId(text: string, runId: string): string {
33
- const short = escapeHtml(shortRunId(runId));
34
- const index = text.indexOf(short);
35
- if (index < 0) return text;
25
+ /** 现时:决定时间列写 `19:35` 还是 `11-07 18:24` */
26
+ now: number;
27
+ }
28
+
29
+ /** 徽章颜色:同一个概念全站一色(灰=排队、蓝=等人、黄=在跑、绿=落地、红=卡死、暗红=被拒、淡灰=归档) */
30
+ export type BadgeTone = "gray" | "blue" | "green" | "red" | "yellow" | "darkred" | "faint";
31
+
32
+ export interface ListRow {
33
+ runId: string;
34
+ /** 状态徽章:`title` 放不能进表格的次要事实(五闸结论 / 层级 / 目标文件 / 失败原因) */
35
+ badge: { text: string; tone: BadgeTone; title: string };
36
+ /** 该条自己的时间;null 表示读不到(时间列写「—」,不拿别的字段冒充) */
37
+ at: number | null;
38
+ skill: string;
39
+ /** 提案改动行数;没有提案(还没出稿)就是 null,列里写「—」 */
40
+ delta: { type: string; added: number; removed: number } | null;
41
+ session: SessionRef;
42
+ /** 操作列(已转义的 HTML 片段) */
43
+ ops: string;
44
+ /** 要不要人动手:组标题的「N 条需要处理」靠它算 */
45
+ needsAction: boolean;
46
+ }
47
+
48
+ /** 详情链接:文字是 runId 末 8 位,`title` 是完整 runId(真实数据前 8 位全同,只能靠尾巴区分) */
49
+ function detailLink(runId: string): string {
36
50
  const href = escapeHtml(`/?view=run&runId=${encodeURIComponent(runId)}`);
37
- const anchor = `<a class="run" href="${href}">${short}</a>`;
38
- return `${text.slice(0, index)}${anchor}${text.slice(index + short.length)}`;
51
+ // runId 只放 title 与详情页:末 8 位混在操作列里(详情 …94137752 · 重跑 · 日志)看着像乱码
52
+ return `<a class="rid" href="${href}" title="${escapeHtml(runId)}">详情</a>`;
39
53
  }
40
54
 
41
- /**
42
- * 「会话」列:pi sessionId 前 8 位做成链接,点进去看那段对话的原文(`?view=session`)。
43
- *
44
- * 为什么必须有(用户 2026-11-08:「面板展示的也应该是 pi sessionId 否则我都不知道怎么看对应会话」):
45
- * 行上原来只有 runId(会话哈希8 + 时间戳),对着 TUI 里的 sessionId 对不上号,
46
- * 想回去看那段对话只能自己到 `~/.pi/agent/sessions/` 里翻。
47
- * 只显示前 8 位是为了不把一行撑开;完整 id 放进 `title`(悬停可见),链接里带完整 id。
48
- */
49
- function sessionCell(session: SessionRef | undefined): string {
50
- const href = session === undefined ? null : sessionHref(session);
51
- if (href === null || session === undefined || session.sessionId === null) {
52
- // 老 run.json 可能没有 sessionId:写「—」而不是凭空造一个空链接(点进去只会 404)
53
- return ' · <span class="hint">会话 —</span>';
54
- }
55
- return ` · <a class="sess" href="${escapeHtml(href)}" title="${escapeHtml(session.sessionId)}">会话 ${escapeHtml(
56
- session.sessionId.slice(0, 8),
57
- )}</a>`;
58
- }
59
-
60
- /** 行尾附加信息:runId 变链接、会话列(都可缺省,缺了就不渲染) */
61
- interface RowMeta {
62
- runId?: string;
63
- session?: SessionRef;
64
- }
65
-
66
- /** 一行;detail 为空就不给 `<details>`(空折叠框比没有更烦);给了 runId 就把抬头里的 runId 变链接 */
67
- function row(head: string, detail: string, acts = "", meta: RowMeta = {}): string {
68
- const text = escapeHtml(head);
69
- const linked = meta.runId === undefined ? text : linkRunId(text, meta.runId);
70
- const rendered = `${linked}${sessionCell(meta.session)}`;
71
- const inner =
72
- detail.length === 0
73
- ? `<span class="head">${rendered}</span>`
74
- : `<details><summary class="head">${rendered}</summary><pre>${escapeHtml(detail)}</pre></details>`;
75
- return acts.length === 0 ? `<li class="row">${inner}</li>` : `<li class="row">${inner}\n${acts}</li>`;
55
+ function logLink(runId: string): string {
56
+ return `<a href="${escapeHtml(`/log?runId=${encodeURIComponent(runId)}`)}">日志</a>`;
76
57
  }
77
58
 
78
59
  /** 一个 `/action` 表单:csrf / name / runId / view 全走 hidden,按钮就是提交键 */
79
- function runForm(name: "rerun" | "cleanup", runId: string, ctx: RowContext): string {
80
- const label = name === "rerun" ? "重跑" : "清理记录";
81
- const cls = name === "rerun" ? "" : ' class="danger"';
82
- const title = name === "rerun" ? "" : ' title="移到 runs/.trash/,不会删数据"';
60
+ function actionForm(name: "rerun" | "cleanup", runId: string, ctx: RowContext): string {
61
+ const label = name === "rerun" ? "重跑" : "清理";
62
+ const title = name === "rerun" ? "重跑这条 run(花 token)" : "移到 runs/.trash/,不会删数据";
83
63
  return `<form method="post" action="/action">
84
64
  <input type="hidden" name="csrf" value="${escapeHtml(ctx.csrf)}">
85
65
  <input type="hidden" name="name" value="${name}">
86
66
  <input type="hidden" name="runId" value="${escapeHtml(runId)}">
87
67
  <input type="hidden" name="view" value="${ctx.view}">
88
- <button type="submit"${cls}${title}>${label}</button>
68
+ <button type="submit" title="${title}">${label}</button>
89
69
  </form>`;
90
70
  }
91
71
 
92
72
  /**
93
- * 行内操作条。`stale` 才给「清理记录」:清理是把目录移走,非卡死的 run 还在写盘,
94
- * 移走等于把活进程的 cwd 抽掉(Linux 上不报错,但之后的写入全进孤儿 inode)。
73
+ * 待处理 / 复盘报告的行操作:详情 + 重跑(+ 卡死时的清理)+ 日志。
74
+ * 清理只给 `stale`:非卡死的 run 还在写盘,把目录移走等于抽掉活进程的 cwd。
75
+ * 重跑只在可写面板出现 —— 只读面板给一个点了没反应的按钮等于骗人。
95
76
  */
96
- function rowActs(runId: string, ctx: RowContext, stale: boolean): string {
97
- const parts: string[] = [];
77
+ function activeOps(runId: string, ctx: RowContext, stale: boolean): string {
78
+ const parts = [detailLink(runId)];
98
79
  if (ctx.writable) {
99
- parts.push(runForm("rerun", runId, ctx));
100
- if (stale) parts.push(runForm("cleanup", runId, ctx));
80
+ parts.push(actionForm("rerun", runId, ctx));
81
+ if (stale) parts.push(actionForm("cleanup", runId, ctx));
101
82
  }
102
- parts.push(`<a class="btn" href="/log?runId=${encodeURIComponent(runId)}">看日志</a>`);
103
- return `<div class="acts">\n${parts.join("\n")}\n</div>`;
83
+ parts.push(logLink(runId));
84
+ return parts.join(" · ");
104
85
  }
105
86
 
106
- /** 档案类行的操作:只有两个只读链接(看 diff / 看日志),不挂重跑 */
107
- function archiveActs(runId: string): string {
108
- const id = encodeURIComponent(runId);
109
- return `<div class="acts">\n<a class="btn" href="${escapeHtml(`/?view=run&runId=${id}`)}">看 diff</a>\n<a class="btn" href="${escapeHtml(`/log?runId=${id}`)}">看日志</a>\n</div>`;
87
+ /**
88
+ * 档案类(已采纳 / 已处理)的操作:只有详情与日志。
89
+ * 一个已采纳的 run 旁边放「重跑」,只会让人以为它能重放整个采纳流程。
90
+ */
91
+ function archiveOps(runId: string): string {
92
+ return `${detailLink(runId)} · ${logLink(runId)}`;
110
93
  }
111
94
 
112
- export function pendingRows(items: readonly PendingItem[], ctx: RowContext): string {
113
- const badge: Record<PendingItem["kind"], string> = { running: "进行中", approval: "待批", stale: "卡死", reviewed: "待入队" };
114
- return items
115
- .map((item) => {
116
- const skill = item.skill.length > 0 ? ` · ${item.skill}` : "";
117
- return row(
118
- `[${badge[item.kind]}] ${shortRunId(item.runId)}${skill} · ${formatStamp(item.at)}`,
119
- withId(item.runId, item.detail),
120
- rowActs(item.runId, ctx, item.kind === "stale"),
121
- { session: item.session },
122
- );
123
- })
124
- .join("\n");
95
+ /** 一个审批动作:csrf 与 runId 都走 hidden,按钮就是提交键 */
96
+ function approvalForm(action: "approve" | "reject", item: ApprovalItem, csrf: string): string {
97
+ const label = action === "approve" ? "批准" : "拒绝";
98
+ return `<form method="post" action="/${action}">
99
+ <input type="hidden" name="csrf" value="${escapeHtml(csrf)}">
100
+ <input type="hidden" name="runId" value="${escapeHtml(item.runId)}">
101
+ <button type="submit"${action === "reject" ? ' class="reject"' : ""}>${label}</button>
102
+ </form>`;
125
103
  }
126
104
 
127
- export function doneRows(items: readonly DoneItem[]): string {
128
- return items
129
- .map((item) => {
130
- const skill = item.skill.length > 0 ? ` · ${item.skill}` : "";
131
- const level = item.level === "-" ? "" : ` · ${item.level}`;
132
- return row(
133
- `[${item.status}] ${shortRunId(item.runId)}${skill}${level} · ${formatStamp(item.at)}`,
134
- withId(item.runId, item.detail),
135
- archiveActs(item.runId),
136
- { runId: item.runId, session: item.session },
137
- );
138
- })
139
- .join("\n");
105
+ /** 待处理:五种 kind 各一个徽章;「待评审 / 待入队」是灰的(它们在排队,不需要人动手) */
106
+ export function pendingRows(items: readonly PendingItem[], ctx: RowContext): ListRow[] {
107
+ const badge: Record<PendingItem["kind"], { text: string; tone: BadgeTone }> = {
108
+ running: { text: "复盘中", tone: "yellow" },
109
+ approval: { text: "待审批", tone: "blue" },
110
+ stale: { text: "卡死", tone: "red" },
111
+ "to-review": { text: "待评审", tone: "gray" },
112
+ reviewed: { text: "待入队", tone: "gray" },
113
+ };
114
+ return items.map((item) => ({
115
+ runId: item.runId,
116
+ badge: { ...badge[item.kind], title: item.detail },
117
+ at: item.at,
118
+ skill: item.skill,
119
+ delta: null,
120
+ session: item.session,
121
+ ops: activeOps(item.runId, ctx, item.kind === "stale"),
122
+ needsAction: item.kind === "stale" || item.kind === "approval",
123
+ }));
124
+ }
125
+
126
+ /** 已处理:终态各一色;被拒绝是暗红(是结论,不是故障),失败才是红 */
127
+ export function doneRows(items: readonly DoneItem[]): ListRow[] {
128
+ const tone: Record<string, { text: string; tone: BadgeTone }> = {
129
+ applied: { text: "已采纳", tone: "green" },
130
+ rejected: { text: "已拒绝", tone: "darkred" },
131
+ failed: { text: "失败", tone: "red" },
132
+ rolledback: { text: "已回滚", tone: "faint" },
133
+ };
134
+ return items.map((item) => ({
135
+ runId: item.runId,
136
+ badge: { ...(tone[item.status] ?? { text: item.status, tone: "faint" as BadgeTone }), title: item.detail },
137
+ at: item.at,
138
+ skill: item.skill,
139
+ delta: null,
140
+ session: item.session,
141
+ ops: archiveOps(item.runId),
142
+ needsAction: false,
143
+ }));
140
144
  }
141
145
 
142
146
  /** 五闸一句话:没记录时写「无记录」而不是「失败」(判不了 ≠ 没过) */
@@ -146,138 +150,77 @@ function gateText(item: AppliedItem): string {
146
150
  return failed === 0 ? "五闸:全 ok" : `五闸:${failed} 项失败`;
147
151
  }
148
152
 
149
- function appliedDetail(item: AppliedItem): string {
150
- const gates =
151
- item.gates.length === 0
152
- ? ["五闸:没有 06-verify.json(无法判定)"]
153
- : item.gates.map((gate) => ` ${gate.gate} ${gate.name} ${gate.ok ? "✓" : "✗"}`);
154
- return [
155
- `层级:${item.level}`,
156
- `采纳:${formatStamp(item.appliedAt)}`,
157
- `提案:${item.type} +${item.added}/-${item.removed} 行`,
158
- ...gates,
159
- item.gatesOk === false ? "注意:有闸没过却仍被采纳,先看 06-verify.json" : "",
160
- "完整 diff:点上方 runId 进详情页",
161
- ]
162
- .filter((line) => line.length > 0)
163
- .join("\n");
164
- }
165
-
166
153
  /** 已采纳:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸结论(用户要的就是这五个字段) */
167
- export function appliedRows(items: readonly AppliedItem[]): string {
168
- return items
169
- .map((item) => {
170
- const skill = item.skill.length > 0 ? ` · ${item.skill}` : "";
171
- const delta = ` · ${item.type} +${item.added}/-${item.removed}`;
172
- return row(
173
- `[applied] ${shortRunId(item.runId)}${skill}${delta} · ${formatStamp(item.appliedAt)} · ${gateText(item)}`,
174
- appliedDetail(item),
175
- archiveActs(item.runId),
176
- { runId: item.runId, session: item.session },
177
- );
178
- })
179
- .join("\n");
180
- }
181
-
182
- /** 待批条目的抬头:一眼看到「谁、改多少、评审怎么判」,详情留给 targets 与完整 runId */
183
- function approvalHead(item: ApprovalItem): string {
184
- const proposal = item.proposal ? ` · ${item.proposal.type} +${item.proposal.added}/-${item.proposal.removed}` : " · 无提案";
185
- const verdict = item.review ? ` · ${item.review.verdict ?? "无 verdict"}/blocking ${item.review.blocking}` : "";
186
- return `[${item.level}] ${shortRunId(item.runId)} · ${item.skill}${proposal}${verdict} · ${formatStamp(item.createdAt)}`;
187
- }
188
-
189
- function approvalDetail(item: ApprovalItem): string {
190
- return [
191
- `层级:${item.level}(必须人批;auto 级不在这里)`,
192
- `目标:${item.targets.join("、") || "(未写 targets)"}`,
193
- `入队:${formatStamp(item.createdAt)}`,
194
- item.proposal
195
- ? `提案:${item.proposal.type} +${item.proposal.added}/-${item.proposal.removed} 行`
196
- : "提案:读不到 02-proposal.json",
197
- item.review ? `评审:verdict=${item.review.verdict ?? "(未写)"} · blocking ${item.review.blocking} 条` : "评审:读不到 03-review.json",
198
- item.detail ? `备注:${item.detail}` : "",
199
- ]
200
- .filter((line) => line.length > 0)
201
- .join("\n");
154
+ export function appliedRows(items: readonly AppliedItem[]): ListRow[] {
155
+ return items.map((item) => ({
156
+ runId: item.runId,
157
+ badge: { text: "已采纳", tone: "green", title: `${item.level} 级 · ${gateText(item)}` },
158
+ at: item.appliedAt,
159
+ skill: item.skill,
160
+ delta: { type: item.type, added: item.added, removed: item.removed },
161
+ session: item.session,
162
+ ops: archiveOps(item.runId),
163
+ needsAction: false,
164
+ }));
165
+ }
166
+
167
+ /** 待审批:每条都要人动手,所以整页 `needsAction` 全是 true */
168
+ export function approvalRows(items: readonly ApprovalItem[], ctx: RowContext): ListRow[] {
169
+ return items.map((item) => {
170
+ const ops = ctx.writable
171
+ ? `${approvalForm("approve", item, ctx.csrf)} · ${approvalForm("reject", item, ctx.csrf)} · ${detailLink(item.runId)}`
172
+ : detailLink(item.runId);
173
+ const facts = [
174
+ `${item.level} 级(必须人批)`,
175
+ `目标:${item.targets.join("、") || "(未写 targets)"}`,
176
+ item.review ? `评审:verdict=${item.review.verdict ?? "(未写)"} · blocking ${item.review.blocking} 条` : "评审:读不到 03-review.json",
177
+ ].join(" · ");
178
+ return {
179
+ runId: item.runId,
180
+ badge: { text: "待审批", tone: "blue", title: facts },
181
+ at: item.createdAt,
182
+ skill: item.skill,
183
+ delta: item.proposal
184
+ ? { type: item.proposal.type, added: item.proposal.added, removed: item.proposal.removed }
185
+ : null,
186
+ session: item.session,
187
+ ops,
188
+ needsAction: true,
189
+ };
190
+ });
191
+ }
192
+
193
+ /** 复盘报告:状态文案直接取 run 状态(analyzed 写「已分析」,stale 写「卡死」) */
194
+ export function reportRows(reports: readonly RunReport[], ctx: RowContext): ListRow[] {
195
+ const tone: Record<string, { text: string; tone: BadgeTone }> = {
196
+ analyzed: { text: "已分析", tone: "gray" },
197
+ applied: { text: "已采纳", tone: "green" },
198
+ rejected: { text: "已拒绝", tone: "darkred" },
199
+ running: { text: "复盘中", tone: "yellow" },
200
+ stale: { text: "卡死", tone: "red" },
201
+ failed: { text: "失败", tone: "red" },
202
+ };
203
+ return reports.map((report) => {
204
+ const facts = [
205
+ `触发:${report.trigger}`,
206
+ `findings:${report.findings.total} 条`,
207
+ report.review ? `评审:${report.review.status} · blocking ${report.review.blocking} 条` : "评审:无",
208
+ report.failure ?? "",
209
+ ]
210
+ .filter((line) => line.length > 0)
211
+ .join(" · ");
212
+ return {
213
+ runId: report.runId,
214
+ badge: { ...(tone[report.status] ?? { text: report.status, tone: "faint" as BadgeTone }), title: facts },
215
+ at: report.startedAt,
216
+ skill: report.proposal?.skill ?? "",
217
+ delta: report.proposal
218
+ ? { type: report.proposal.type, added: report.proposal.added, removed: report.proposal.removed }
219
+ : null,
220
+ session: report.session,
221
+ ops: activeOps(report.runId, ctx, report.status === "stale"),
222
+ needsAction: report.status === "stale",
223
+ };
224
+ });
202
225
  }
203
226
 
204
- /** 一个审批动作表单:csrf 与 runId 都走 hidden,按钮就是提交键 */
205
- function actionForm(action: "approve" | "reject", item: ApprovalItem, csrf: string): string {
206
- const label = action === "approve" ? "批准" : "拒绝";
207
- const cls = action === "approve" ? "" : ' class="reject"';
208
- return `<form method="post" action="/${action}">
209
- <input type="hidden" name="csrf" value="${escapeHtml(csrf)}">
210
- <input type="hidden" name="runId" value="${escapeHtml(item.runId)}">
211
- <button type="submit"${cls}>${label}</button>
212
- </form>`;
213
- }
214
-
215
- export function approvalRows(items: readonly ApprovalItem[], ctx: RowContext): string {
216
- return items
217
- .map((item) => {
218
- const detail = `<details><summary class="head">${escapeHtml(approvalHead(item))}${sessionCell(
219
- item.session,
220
- )}</summary><pre>${escapeHtml(
221
- withId(item.runId, approvalDetail(item)),
222
- )}</pre></details>`;
223
- if (!ctx.writable) return `<li class="row">${detail}</li>`;
224
- const acts = `<div class="acts">\n${actionForm("approve", item, ctx.csrf)}\n${actionForm("reject", item, ctx.csrf)}\n</div>`;
225
- return `<li class="row">${detail}\n${acts}</li>`;
226
- })
227
- .join("\n");
228
- }
229
-
230
- function gateLine(report: RunReport): string {
231
- if (!report.verify) return "验证:还没跑过五闸(未采纳)";
232
- const marks = report.verify.gates.map((gate) => `${gate.gate}${gate.ok ? "✓" : "✗"}`).join(" ");
233
- return `验证:${report.verify.status} · ${formatStamp(report.verify.at)} · ${marks || "(未记录闸门)"}`;
234
- }
235
-
236
- /** 报告的展开内容:按「结论 → 依据 → 执行」的顺序排,最后才是数字,方便先看结论 */
237
- function reportDetail(report: RunReport): string {
238
- const lines = [`状态:${report.status} · 触发:${report.trigger} · 开始:${formatStamp(report.startedAt)}`];
239
- // 失败留档先给原因与日志末行:先知道“为什么没有 findings”,再看其它字段
240
- if (report.failure) lines.push(report.failure);
241
- const rows = report.findings.rows.map((item) => ` · ${item.skill} · ${item.kind} · ${item.reason}`);
242
- lines.push(`findings:${report.findings.total} 条`);
243
- lines.push(...rows);
244
- if (report.findings.total > report.findings.rows.length) {
245
- lines.push(` …另有 ${report.findings.total - report.findings.rows.length} 条(见 01-findings.json)`);
246
- }
247
- lines.push(
248
- report.proposal
249
- ? `提案:${report.proposal.type} ${report.proposal.skill} +${report.proposal.added}/-${report.proposal.removed}`
250
- : "提案:无",
251
- );
252
- lines.push(
253
- report.review
254
- ? `评审:${report.review.status}${report.review.verdict ? ` verdict=${report.review.verdict}` : ""} · blocking ${report.review.blocking} 条`
255
- : "评审:无",
256
- );
257
- lines.push(
258
- report.decision
259
- ? `决策:${report.decision.status} · ${report.decision.approver} · ${formatStamp(report.decision.at)}${report.decision.detail ? ` · ${report.decision.detail}` : ""}`
260
- : "决策:未采纳(没有 04-decision.json)",
261
- );
262
- lines.push(gateLine(report));
263
- return lines.join("\n");
264
- }
265
-
266
- export function reportRows(reports: readonly RunReport[], ctx: RowContext): string {
267
- return reports
268
- .map((report) => {
269
- // 行上就要能看出「有没有东西」:全部折叠成一行状态+时间,等于用户得逐个点开才知道
270
- const findings = report.findings.total > 0 ? ` · ${report.findings.total} 条发现` : " · 无发现";
271
- const proposal = report.proposal ? ` · ${report.proposal.type} ${report.proposal.skill}` : "";
272
- const verdict = report.review?.verdict ? ` · ${report.review.verdict}` : "";
273
- // 卡死的 run 与其它状态同样重要,但中文标签才有用:行首就是用户扫的关键词
274
- const label = report.status === "stale" ? "卡死" : report.status;
275
- return row(
276
- `[${label}] ${shortRunId(report.runId)}${findings}${proposal}${verdict} · ${formatStamp(report.startedAt)}`,
277
- withId(report.runId, reportDetail(report)),
278
- rowActs(report.runId, ctx, report.status === "stale"),
279
- { runId: report.runId, session: report.session },
280
- );
281
- })
282
- .join("\n");
283
- }
@@ -1,26 +1,34 @@
1
1
  /**
2
- * 会话详情页(`?view=session&sessionId=...`)的渲染:概要 + 对话正文 + 原文件指路。
2
+ * 会话详情页(`?view=session&sessionId=...`)的渲染:顶部一行 + 概览 + 目录 + 消息流 + 出口。
3
3
  *
4
- * 页面只有三段:顶部概要(完整 sessionId / 文件路径 / 统计)、中间正文(由 session-view 纯函数产出)、
5
- * 底部一行「原文件 路径(N 条消息 / M 次工具调用 / 大小 K)」——
6
- * 面板解析不了的情况(超大文件、非 JSONL)必须能一键回到原文件,不然这一页就成了死胡同。
4
+ * 这一页的四件东西(用户 2026-11-08 现场反馈):
5
+ * 1. **顶部一行**:`← 返回待处理` / `会话 01a1b90f` / 原文件路径 / `跳到最新 ↓` —— 原来没有返回入口,进来就出不去;
6
+ * 2. **左侧粘性目录**(`>=1200px`):会话动辄几千行,一路往下滚太累;窄屏改用顶部折叠目录兜底;
7
+ * 3. **只看对话**(`?only=user`):只在服务端过滤,工具调用压成一行(不引 JS);
8
+ * 4. **底部出口**:说明渲染了多少条、还剩多少没显示、原文件在哪 —— 面板解析不了的,一键回到原文件。
7
9
  *
8
10
  * 找不到会话文件不报错:换机器 / 清理过 sessions 是常态,说清「会话文件已不在本机」比 500 有用。
9
11
  */
10
12
  import type { WebState } from "./data.ts";
13
+ import { renderToc, renderTranscript } from "./session-render.ts";
11
14
  import type { SessionSource } from "./session-file.ts";
12
- import { renderSessionBody } from "./session-view.ts";
15
+ import { MAX_MESSAGES } from "./session-view.ts";
13
16
  import { shellPage } from "./shell.ts";
14
- import { escapeHtml } from "./text.ts";
17
+ import { escapeHtml, formatSpan } from "./text.ts";
15
18
 
16
- /** 概要的一行:标签 + 值(值必须已转义或本身就是标签串) */
17
- function kv(label: string, value: string): string {
18
- return `<div class="kv-row"><span class="k">${label}</span><span class="v">${value}</span></div>`;
19
+ /** 大小:字节 / KB / MB 三档,MB 给两位小数(5MB 级会话差 0.5MB reader 也看得出) */
20
+ function formatSize(bytes: number): string {
21
+ if (bytes < 1024) return `${bytes}B`;
22
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
23
+ return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
19
24
  }
20
25
 
21
- /** 大小:小于 1KB 说字节,其余按 KB 取整(MB 级数字太长,看个量级就够) */
22
- function formatSize(bytes: number): string {
23
- return bytes < 1024 ? `${bytes}B` : `${Math.round(bytes / 1024)}KB`;
26
+ /** 本页自己的地址:`sessionId` + 可选 `file` + 可选 `only=user`(全部走编码后的枚举值) */
27
+ function sessionUrl(sessionId: string, file: string | null, onlyUser: boolean): string {
28
+ const params = new URLSearchParams({ view: "session", sessionId });
29
+ if (file !== null) params.set("file", file);
30
+ if (onlyUser) params.set("only", "user");
31
+ return `/?${params.toString()}`;
24
32
  }
25
33
 
26
34
  /** 缺文件时的正文:给三句人话,说明为什么没有、去哪里看 */
@@ -30,38 +38,101 @@ function missingBody(sessionId: string): string {
30
38
  可能是换了机器、或 sessions 目录被清理过 —— 这一段对话的原文已经取不到了。</p>`;
31
39
  }
32
40
 
33
- /** 正文:有文件就渲染对话,没文件就给缺文件说明 */
34
- function bodyOf(source: SessionSource): string {
35
- if (source.file === null || source.transcript === null) return missingBody(source.sessionId);
36
- return renderSessionBody(source.transcript, { dropped: source.transcript.dropped });
41
+ /** 顶部一行:返回入口 + 会话标识 + 原文件 + 跳到最新(长路径自己换行,不挤掉别的元素) */
42
+ function topRow(source: SessionSource, lastId: string | null): string {
43
+ const id = escapeHtml(source.sessionId.slice(0, 8));
44
+ const file =
45
+ source.file === null
46
+ ? `<span class="off">会话文件已不在本机</span>`
47
+ : `<code title="${escapeHtml(source.file)}">${escapeHtml(source.file)}</code>`;
48
+ const toEnd = lastId === null ? "" : `<a class="to-end" href="#${lastId}">跳到最新 ↓</a>`;
49
+ return `<div class="ses-top">
50
+ <a href="/?view=pending&amp;range=all">← 返回待处理</a>
51
+ <span class="ses-id">会话 <code>${id}</code></span>
52
+ <span class="ses-file">原文件 ${file}</span>
53
+ ${toEnd}
54
+ </div>`;
55
+ }
56
+
57
+ /** 概览卡片:消息数 / 工具调用 / 大小 / 时间跨度 + 「只看对话」切换 */
58
+ function overview(
59
+ source: SessionSource,
60
+ toggle: string,
61
+ onlyUser: boolean,
62
+ ): string {
63
+ const transcript = source.transcript;
64
+ if (transcript === null) return "";
65
+ const span = formatSpan(transcript.firstAt, transcript.lastAt);
66
+ const parts = [
67
+ `消息 ${transcript.messages}`,
68
+ `工具调用 ${transcript.toolCalls}`,
69
+ formatSize(source.sizeBytes),
70
+ ];
71
+ if (span.length > 0) parts.push(span);
72
+ return `<p class="ov">${parts.join(" · ")} <span class="ov-link">${toggle}${onlyUser ? "(当前只看对话)" : ""}</span></p>`;
37
73
  }
38
74
 
39
75
  /**
40
- * 页脚那一行是这一页的「出口」:绝对路径可以直接粘到终端里 `rg` / `read`,
41
- * 统计数字则用来说明「页面里看到的是全面的还是截断的」。
76
+ * 正文骨架:左侧粘性目录(宽屏)+ 消息流;窄屏把同一份目录换成顶部折叠块。
77
+ * 目录与消息流共用 `renderTranscript` 的产出,锚点 id 与 href 一一对应。
42
78
  */
43
- function footerOf(source: SessionSource): string {
44
- if (source.file === null || source.transcript === null) return `原文件:<span class="off">会话文件已不在本机</span>`;
45
- const { messages, toolCalls } = source.transcript;
46
- return `原文件 <code>${escapeHtml(source.file)}</code>(${messages} 条消息 / ${toolCalls} 次工具调用 / ${formatSize(source.sizeBytes)})`;
79
+ function streamOf(source: SessionSource, onlyUser: boolean): { html: string; lastId: string | null } {
80
+ const transcript = source.transcript;
81
+ if (transcript === null) return { html: missingBody(source.sessionId), lastId: null };
82
+ const view = renderTranscript(transcript, {
83
+ dropped: transcript.dropped,
84
+ onlyUser,
85
+ expandHref: sessionUrl(source.sessionId, source.file, false),
86
+ });
87
+ const toc = renderToc(view.toc);
88
+ const lastId = view.shown === 0 ? null : `m-${view.shown}`;
89
+ return {
90
+ html: `<div class="ses-wrap">
91
+ <aside class="toc">${toc}</aside>
92
+ <div class="ses-body">
93
+ <details class="toc-fold"><summary>目录(${view.toc.length} 条用户消息)</summary>
94
+ <div class="toc-list">${toc}</div>
95
+ </details>
96
+ ${view.body}
97
+ ${lastId === null ? "" : `<a class="to-top" href="#top">↑ 回到顶部</a>`}
98
+ </div>
99
+ </div>`,
100
+ lastId,
101
+ };
47
102
  }
48
103
 
49
- export function renderSessionPage(source: SessionSource, options: { state: WebState }): string {
50
- const href = source.file === null ? null : source.file;
51
- const body = `<section>
52
- <h2>会话</h2>
53
- <div class="kv">
54
- ${kv("sessionId", `<code>${escapeHtml(source.sessionId)}</code>`)}
55
- ${kv("会话文件", href === null ? `<span class="off">会话文件已不在本机</span>` : `<code>${escapeHtml(href)}</code>`)}
56
- ${kv("工具调用", "折叠块默认收起,点标题展开(参数摘要 ≤400 字、结果前 200 字)")}
57
- </div>
58
- </section>
59
- ${bodyOf(source)}`;
104
+ /** 底部出口:渲染了多少、还剩多少、原文件在哪(面板解析不了时全靠这一行) */
105
+ function exitLine(source: SessionSource): string {
106
+ if (source.file === null || source.transcript === null) {
107
+ return `原文件:<span class="off">会话文件已不在本机</span>`;
108
+ }
109
+ const { dropped, messages } = source.transcript;
110
+ const file = `<code>${escapeHtml(source.file)}</code>`;
111
+ if (dropped > 0) {
112
+ return `已渲染最后 ${MAX_MESSAGES} 条 · 前 ${dropped} 条未显示(原文件 ${file},共 ${messages} 条消息)`;
113
+ }
114
+ return `已渲染全部 ${messages} 条消息(原文件 ${file})`;
115
+ }
116
+
117
+ export function renderSessionPage(source: SessionSource, options: { state: WebState; onlyUser?: boolean }): string {
118
+ const onlyUser = options.onlyUser === true;
119
+ const transcript = source.transcript;
120
+ const toggle =
121
+ transcript === null
122
+ ? ""
123
+ : onlyUser
124
+ ? `<a href="${escapeHtml(sessionUrl(source.sessionId, source.file, false))}">看全部</a>`
125
+ : `<a href="${escapeHtml(sessionUrl(source.sessionId, source.file, true))}">只看对话</a>`;
126
+ // 先渲染消息流:「跳到最新」要指向**实际渲染出的最后一块**,而不是 entries 条数(工具块与空正文都不占块号)
127
+ const stream = streamOf(source, onlyUser);
128
+ const body = `${topRow(source, stream.lastId)}
129
+ ${overview(source, toggle, onlyUser)}
130
+ ${stream.html}`;
60
131
  return shellPage({
61
132
  state: options.state,
62
133
  title: `会话 ${source.sessionId.slice(0, 8)}`,
63
134
  active: null,
64
135
  body,
65
- foot: footerOf(source),
136
+ foot: exitLine(source),
66
137
  });
67
138
  }