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
@@ -1,124 +1,189 @@
1
1
  /**
2
- * 面板的内联样式:单文件放着是因为只有一处消费(render.ts),拆出来只是为了把 render.ts 压在 300 行内。
3
- * 不引外部 CSS:面板必须离线可用,一条网络请求都不发。
2
+ * 面板的全部样式:一份内联 CSS,不引外部资源(面板要离线可用,一条网络请求都不发)。
3
+ *
4
+ * 三块约定(改样式先看这三行,别各自发明):
5
+ * 1. **颜色只从 `:root` 的令牌里取**:同一个概念(蓝=要看、绿=已落地、黄=在跑、红=卡死)全站一色;
6
+ * 2. **字号只有 12 / 13 / 14 / 20 四档,间距只用 4 / 8 / 12 / 16 / 24** —— 随手写 15px / 7px 是页面显得毛糙的根源;
7
+ * 3. **列表行是表格、不是卡片**:一行一条,扫读靠列对齐;操作是小号链接,不是每行两个大按钮。
4
8
  */
5
9
  export const STYLE = `
6
- :root { color-scheme: dark }
10
+ :root {
11
+ color-scheme: dark;
12
+ --bg: #0f1115; --panel: #161a21; --line: #252b36; --fg: #dfe3ea; --muted: #8b95a7;
13
+ --blue: #3884ff; --green: #2ea043; --red: #d1435b; --yellow: #c69026;
14
+ }
7
15
  * { box-sizing: border-box }
8
- body { margin: 0; padding: 24px; background: #0f1115; color: #d7dce5;
9
- font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace }
10
- h1 { margin: 0 0 4px; font-size: 18px; font-weight: 600 }
11
- .bar { margin: 0 0 16px; color: #8d99ae }
12
- .bar a { color: #7cc4ff; text-decoration: none }
13
- .notice { margin: 0 0 12px; padding: 8px 12px; border-left: 2px solid #3f9d5a; background: #131a17; color: #b6e2c4 }
14
- .notice.err { border-left-color: #c05c5c; background: #1a1313; color: #f0bdbd }
15
- nav { display: flex; gap: 12px; margin-bottom: 16px; border-bottom: 1px solid #232733; padding-bottom: 8px }
16
- nav a { color: #8d99ae; text-decoration: none; padding: 2px 8px; border-radius: 4px }
17
- nav a:hover { background: #1a1f29 }
18
- nav a.active { color: #0f1115; background: #7cc4ff }
19
- ul { list-style: none; margin: 0; padding: 0 }
20
- li.row { border-bottom: 1px solid #1c202a; padding: 8px 0 }
21
- summary.head { cursor: pointer; color: #cfe3ff }
22
- span.head { color: #cfe3ff }
23
- pre { margin: 8px 0 0; padding: 12px; background: #141821; border-left: 2px solid #2b3342;
24
- white-space: pre-wrap; word-break: break-word; color: #aeb7c4 }
25
- .empty { color: #6f7a8c; padding: 24px 0 }
26
- .acts { display: flex; gap: 8px; margin-top: 8px }
27
- .acts form { display: inline; margin: 0 }
28
- button { font: inherit; color: #0f1115; background: #7cc4ff; border: 0; border-radius: 4px; padding: 2px 12px; cursor: pointer }
29
- button.reject { background: #6f7a8c; color: #0f1115 }
30
- button.danger { background: #c05c5c; color: #fff }
16
+ body { margin: 0; background: var(--bg); color: var(--fg);
17
+ font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace }
18
+ .wrap { max-width: 980px; margin: 0 auto; padding: 0 16px 24px }
19
+ a { color: var(--blue); text-decoration: none }
20
+ a:hover { text-decoration: underline }
21
+ code { font: inherit; color: #c8d3e6 }
22
+ h1 { margin: 0; font-size: 20px; font-weight: 600 }
23
+ h2 { margin: 20px 0 8px; font-size: 14px; font-weight: 600; color: #c8d3e6 }
24
+ h3, h4 { margin: 12px 0 4px; font-size: 14px; font-weight: 600; color: #c8d3e6 }
25
+ /* 顶部粘性区:标题 + 一行摘要 + tabs + 四个开关,滚动时永远看得到「现在有几条要处理」 */
26
+ .hd { position: sticky; top: 0; z-index: 10; padding: 12px 0 8px; background: var(--bg);
27
+ border-bottom: 1px solid var(--line) }
28
+ .sum { margin: 4px 0 8px; color: var(--muted); font-size: 12px }
29
+ .sum a { color: #c8d3e6 }
30
+ .tabs { display: flex; gap: 4px; flex-wrap: wrap }
31
+ .tabs a { color: var(--muted); padding: 2px 8px; border-radius: 4px; white-space: nowrap }
32
+ .tabs a:hover { background: var(--panel); text-decoration: none }
33
+ .tabs a.active { color: #fff; background: #1c2740 }
34
+ .tabs .cnt { margin-left: 4px; padding: 0 6px; border-radius: 999px; font-size: 11px;
35
+ background: var(--line); color: var(--fg) }
36
+ .tabs a.active .cnt { background: var(--blue); color: #fff }
37
+ /* 控制条:四个开关与「立即复盘」挤在一行,超宽就横向滚动 —— 换行会把列表挤到屏幕外 */
38
+ .ctl { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0 0 }
39
+ .ctl form { display: inline-flex; margin: 0 }
40
+ .ctl .sw { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px;
41
+ border: 1px solid var(--line); background: var(--panel); font-size: 12px }
42
+ .ctl .state.on { color: var(--green) }
43
+ .ctl .state.off { color: var(--muted) }
44
+ .notice { margin: 12px 0 0; padding: 8px 12px; border-left: 2px solid var(--green); background: #12221a; color: #b6e2c4 }
45
+ .notice.err { border-left-color: var(--red); background: #221316; color: #f0bdbd }
46
+ .warn { margin: 8px 0 0; padding: 4px 8px; border-left: 2px solid var(--yellow); background: #231d11; color: #e8d9a8; font-size: 12px }
47
+ /* 筛选:零 JS 的 GET 表单,选中项必须能在刷新后留住 */
48
+ .filter { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 12px 0; padding: 8px 12px;
49
+ background: var(--panel); border: 1px solid var(--line); border-radius: 6px; font-size: 12px }
50
+ .filter label { color: var(--muted) }
51
+ .filter input, .filter select { font: inherit; color: var(--fg); background: var(--bg);
52
+ border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px }
53
+ button { font: inherit; color: #fff; background: var(--blue); border: 0; border-radius: 4px; padding: 2px 10px; cursor: pointer }
31
54
  button:hover { filter: brightness(1.1) }
32
- /* 顶部控制条:开关状态、切换/立即复盘按钮、最后活动 */
33
- .ctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
34
- margin: 0 0 16px; padding: 8px 12px; background: #141821; border: 1px solid #232733; border-radius: 6px }
35
- .ctl form { display: inline; margin: 0 }
36
- .ctl .state { padding: 1px 8px; border-radius: 4px }
37
- .ctl .state.on { background: #16301f; color: #b6e2c4 }
38
- .ctl .state.off { background: #2a1f1f; color: #e6b0b0 }
39
- .ctl .act { color: #8d99ae }
40
- /* 单个开关:状态 + 后果说明 + 按钮竖排,两个开关之间靠 .ctl 的 gap 分隔 */
41
- .ctl .sw { display: inline-flex; align-items: center; gap: 6px }
42
- .ctl .hint { color: #8d99ae; font-size: 12px }
43
- .warn { margin: 6px 0 0; padding: 6px 10px; border-left: 3px solid #b58900;
44
- background: #2a2418; color: #e8d9a8 }
45
- /* 筛选表单与翻页:零 JS,样式也要看得出它们是一组 */
46
- .filter { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
47
- margin: 0 0 12px; padding: 8px 12px; background: #141821; border: 1px solid #232733; border-radius: 6px }
48
- .filter label { color: #8d99ae }
49
- .filter input, .filter select { font: inherit; color: #d7dce5; background: #0f1115;
50
- border: 1px solid #2b3342; border-radius: 4px; padding: 1px 6px }
51
- .page { display: flex; gap: 12px; align-items: center; margin: 12px 0 0; color: #8d99ae }
52
- .page a { color: #7cc4ff; text-decoration: none }
53
- .page .count { color: #6f7a8c }
54
- .off { color: #3f4654 }
55
- a.btn { color: #7cc4ff; text-decoration: none; padding: 2px 10px; border: 1px solid #2b3342; border-radius: 4px }
56
- a.btn:hover { background: #1a1f29 }
57
- /* 最近操作:结果分三色,ok / partial / error 扫一眼就能分开 */
58
- .audit { margin-top: 24px; padding-top: 12px; border-top: 1px solid #232733 }
59
- .audit h2 { margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #8d99ae }
60
- .audit li { padding: 2px 0; color: #aeb7c4 }
61
- .audit .when { color: #6f7a8c }
62
- .audit code { font: inherit; color: #cfe3ff }
55
+ button.reject { background: #3a4453 }
56
+ button.danger { background: var(--red) }
57
+ a.btn { display: inline-block; color: var(--blue); padding: 1px 8px; border: 1px solid var(--line); border-radius: 4px }
58
+ /* 列表:真表格。一行等于一条 run,列对齐才扫得动;行尾是链接不是按钮 */
59
+ .rows { width: 100%; border-collapse: collapse; table-layout: fixed }
60
+ .rows th { padding: 4px 10px; color: var(--muted); font-size: 12px; font-weight: 600; text-align: left;
61
+ border-bottom: 1px solid var(--line) }
62
+ .rows td { padding: 8px 10px; border-bottom: 1px solid #1b2029; white-space: nowrap; vertical-align: middle }
63
+ .rows tbody tr:hover { background: #1b2029 }
64
+ .rows .status { width: 112px }
65
+ /* 单条会话的 run 不进组,会话名就写在状态格里一行小字(窄屏也不隐藏) */
66
+ .rows .sk-sess { display: block; margin-top: 2px; font-size: 12px }
67
+ .rows .sk-sess a, .rows .sk-sess { color: var(--muted) }
68
+ .rows .time { width: 108px; color: var(--muted); font-size: 12px }
69
+ .rows .skill { max-width: 220px; overflow: hidden; text-overflow: ellipsis; font-size: 12px }
70
+ .rows .delta { width: 92px; color: var(--muted); font-size: 12px }
71
+ .rows .ops { text-align: right; font-size: 12px }
72
+ .rows .ops a, .rows .ops button { color: var(--blue); background: none; border: 0; padding: 0; font-size: 12px; cursor: pointer }
73
+ .rows .ops a:hover, .rows .ops button:hover { text-decoration: underline }
74
+ .rows .ops form { display: inline; margin: 0 }
75
+ .rows .ops .rid { color: var(--muted) }
76
+ /* 同一会话的多条 run 收进一个 details:抬头一行说清「哪个会话、几条、最近什么时候、几条要处理」 */
77
+ .grp { margin: 12px 0; border: 1px solid var(--line); border-radius: 6px; background: #12151b }
78
+ .grp > summary { padding: 8px 12px; color: #c8d3e6; cursor: pointer }
79
+ .grp .rows th { border-bottom: 1px solid var(--line) }
80
+ .badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 12px; border: 1px solid currentColor }
81
+ .badge.gray { color: var(--muted) }
82
+ .badge.blue { color: var(--blue) }
83
+ .badge.green { color: var(--green) }
84
+ .badge.red { color: var(--red) }
85
+ .badge.yellow { color: var(--yellow) }
86
+ .badge.darkred { color: #8f2f3d }
87
+ .badge.faint { color: #5d6675 }
88
+ .empty { color: var(--muted); padding: 24px 0; text-align: center }
89
+ .empty code { color: #c8d3e6 }
90
+ .next { color: var(--muted); text-align: center; font-size: 12px; margin: 0 0 24px }
91
+ .page { display: flex; gap: 12px; align-items: center; justify-content: center; margin: 16px 0 0;
92
+ color: var(--muted); font-size: 12px }
93
+ .page .count { color: var(--muted) }
94
+ .off { color: #45505f }
95
+ .hint { color: var(--muted); font-size: 12px }
96
+ .muted { color: var(--muted) }
97
+ /* 统计 / 明细表:评审 blocking、五闸、技能管理、发现共用(别每处各写一套颜色) */
98
+ .tbl { border-collapse: collapse; width: 100%; margin: 8px 0 0; font-size: 12px }
99
+ .tbl th, .tbl td { border-bottom: 1px solid #1b2029; padding: 4px 8px; text-align: left; vertical-align: top }
100
+ .tbl th { color: var(--muted); font-weight: 600 }
101
+ .tag { display: inline-block; padding: 1px 7px; border-radius: 999px; background: var(--panel); color: var(--muted); font-size: 12px }
102
+ .tag.warn { background: #231d11; color: #e8d9a8 }
103
+ .tag.fail { background: #221316; color: #f0bdbd }
63
104
  .ok { color: #7fc78f }
64
105
  .partial { color: #d6b25e }
65
106
  .fail { color: #e08585 }
66
- /* 表格:评审 blocking / 五闸 / 发现 / 技能管理都用同一套,避免每处各写一份颜色 */
67
- .tbl { border-collapse: collapse; width: 100%; margin: 8px 0 0; font-size: 13px }
68
- .tbl th, .tbl td { border-bottom: 1px solid #1c202a; padding: 4px 8px; text-align: left; vertical-align: top }
69
- .tbl th { color: #8d99ae; font-weight: 600 }
70
- .tbl a { color: #7cc4ff; text-decoration: none }
71
- /* 状态标签:三个标签三色,合成一个就分不出该去批、去改还是去补 stub */
72
- .tag { display: inline-block; padding: 0 6px; border-radius: 4px; background: #1a1f29; color: #aeb7c4; font-size: 12px }
73
- .tag.warn { background: #2a2418; color: #e8d9a8 }
74
- .tag.fail { background: #2a1616; color: #f0bdbd }
75
- .hint { color: #8d99ae; font-size: 12px }
76
- .days { margin: 0 0 8px; color: #8d99ae }
77
- .days a { color: #7cc4ff; text-decoration: none }
78
- .days .on { color: #d7dce5; padding: 0 6px; background: #1a1f29; border-radius: 4px }
79
- /* run 详情页的概要:左标签右值,两列对齐才好扫 */
107
+ .days { margin: 0 0 8px; color: var(--muted); font-size: 12px }
108
+ .days a { margin-right: 8px }
109
+ .days .on { color: var(--fg); background: var(--panel); border-radius: 4px; padding: 0 6px }
110
+ /* run / 技能详情页的键值区:左标签右值,两列对齐才好扫 */
80
111
  .kv { display: grid; grid-template-columns: 6em 1fr; gap: 2px 12px; margin: 8px 0 0 }
81
112
  .kv-row { display: contents }
82
- .kv .k { color: #8d99ae }
83
- .kv .v { color: #d7dce5 }
113
+ .kv .k { color: var(--muted); font-size: 12px }
114
+ .kv .v { color: var(--fg) }
84
115
  .kv-line { margin: 4px 0 }
85
- .kv .v code { font: inherit; color: #cfe3ff }
86
- a.run { color: #cfe3ff; text-decoration: none; border-bottom: 1px dashed #3f4654 }
87
- a.run:hover { border-bottom-color: #7cc4ff }
88
- h2 { margin: 20px 0 6px; font-size: 15px; font-weight: 600; color: #cfe3ff }
89
- /* diff:文件头灰底、hunk 灰字、+ 绿底、- 红底;行号两列宽固定,不随内容抹动 */
90
- .diff { margin: 8px 0 0; padding: 8px 0; background: #0c0e12; border: 1px solid #232733; border-radius: 6px;
91
- overflow-x: auto; white-space: pre; font-size: 12.5px; line-height: 1.5 }
92
- .diff .dl { display: block; padding: 0 8px }
93
- /* 长行必须横向滚动而不是被裁掉:技能 description 动辄两三百字,截断等于看不见改了什么 */
94
- .diff { overflow-x: auto }
95
- .diff .dl { min-width: max-content }
116
+ a.run { color: #c8d3e6; border-bottom: 1px dashed #45505f }
117
+ a.run:hover { border-bottom-color: var(--blue); text-decoration: none }
118
+ a.sess { color: var(--muted); border-bottom: 1px dashed #45505f }
119
+ a.sess:hover { color: var(--blue); text-decoration: none }
120
+ /* diff:文件头灰底、hunk 灰字、+ 绿底、- 红底;行号定宽,长行横向滚动而不是被裁 */
121
+ .diff { margin: 8px 0 0; padding: 8px 0; background: #0c0e12; border: 1px solid var(--line); border-radius: 6px;
122
+ overflow-x: auto; white-space: pre; font-size: 12px; line-height: 1.5 }
123
+ .diff .dl { display: block; padding: 0 8px; min-width: max-content }
96
124
  .diff .ln { display: inline-block; width: 4.5em; color: #4a5364; text-align: right; padding-right: 8px; user-select: none }
97
125
  .diff .tx { white-space: pre }
98
126
  .diff .file { background: #22262f; color: #b8c1d1 }
99
- .diff .hunk { color: #8d99ae }
127
+ .diff .hunk { color: var(--muted) }
100
128
  .diff .add { background: #12281a; color: #b7e7c5 }
101
- .diff .del { background: #2a1616; color: #f0bdbd }
129
+ .diff .del { background: #221316; color: #f0bdbd }
102
130
  .diff .meta { color: #6f7a8c }
103
- /* 改后全文:长文件限高内部滚动,别把后面的评审/五闸顶到屏幕外 */
104
131
  .candidate { max-height: 480px; overflow: auto }
105
- /* 会话详情页:工具调用块默认收起(details 不带 open),抬头是一行可点的摘要 */
106
- details > summary { cursor: pointer }
107
- details.tools { margin: 10px 0; padding: 8px 10px; background: #171b24; border-left: 2px solid #2b3342; border-radius: 4px }
108
- details.tools > summary { color: #cfe3ff }
109
- .tool { margin: 8px 0 0; padding: 8px 0 0; border-top: 1px solid #232733 }
110
- .tool-name { margin: 0; color: #7cc4ff }
111
- .tool-args, .tool-result { margin: 4px 0 0; padding: 8px; background: #141821;
112
- border-left: 1px solid #2b3342; white-space: pre-wrap; word-break: break-word }
113
- .tool-result { color: #8d99ae }
114
- /* 对话消息:谁说的一句话决定左边框颜色,长文本靠 pre 自己的换行 */
115
- .msg { margin: 14px 0; padding-left: 10px; border-left: 2px solid #2b3342 }
116
- .msg.user { border-left-color: #7cc4ff }
117
- .msg .who { margin: 0; color: #8d99ae; font-size: 12px }
118
- .msg pre { margin: 4px 0 0; background: none; padding: 0; border: 0 }
119
- /* 列表页「会话」列:只显示 sessionId 前 8 位,完整值在 title 里 */
120
- a.sess { color: #8d99ae; text-decoration: none; border-bottom: 1px dashed #3f4654 }
121
- a.sess:hover { color: #7cc4ff; border-bottom-color: #7cc4ff }
122
- footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid #232733; color: #6f7a8c }
123
- footer a { color: #7cc4ff }
132
+ pre { margin: 8px 0 0; padding: 12px; background: #12151b; border-left: 2px solid var(--line);
133
+ white-space: pre-wrap; word-break: break-word; color: #aeb7c4; font-size: 12px }
134
+ /* 最近操作:结果三色,ok / partial / error 扫一眼分开 */
135
+ .audit { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--line) }
136
+ .audit ul { list-style: none; margin: 0; padding: 0 }
137
+ .audit li { padding: 2px 0; color: #aeb7c4; font-size: 12px }
138
+ .audit .when { color: var(--muted) }
139
+ /* 会话页:顶部一行(返回 / 会话 / 原文件)+ 概览 + 左侧目录 + 消息流 */
140
+ .ses-top { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin: 16px 0 8px }
141
+ .ses-top .ses-id { color: #c8d3e6 }
142
+ .ses-top .to-end { margin-left: auto; font-size: 12px }
143
+ .ses-file { margin: 0; color: var(--muted); font-size: 12px; min-width: 0; word-break: break-all }
144
+ .ses-file code { user-select: all }
145
+ .ov { margin: 8px 0 16px; padding: 8px 12px; background: var(--panel); border: 1px solid var(--line);
146
+ border-radius: 6px; color: var(--muted); font-size: 12px }
147
+ .ov .ov-link { margin-left: 12px }
148
+ .ses-wrap { display: flex; gap: 24px; align-items: flex-start }
149
+ .toc { display: none }
150
+ .m { scroll-margin-top: 80px }
151
+ /* 消息块:一条一块,抬头上是角色徽章与时刻 */
152
+ .msg { margin: 16px 0; padding: 0 0 0 12px; border-left: 3px solid transparent }
153
+ .msg .mhead { display: flex; align-items: center; gap: 8px; margin-bottom: 4px }
154
+ .msg .at { color: var(--muted); font-size: 12px }
155
+ .msg.user { border-left-color: var(--blue); background: rgba(56, 132, 255, .08); padding: 8px 12px; border-radius: 4px }
156
+ .msg-body { font-size: 14px; line-height: 1.6 }
157
+ .msg-body p { margin: 4px 0 }
158
+ .msg-body ul { margin: 4px 0; padding-left: 20px }
159
+ .msg-body pre.code { margin: 8px 0; border: 1px solid var(--line) }
160
+ details.tools { margin: 12px 0; padding: 8px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px }
161
+ details.tools > summary { cursor: pointer; color: #c8d3e6; font-size: 12px }
162
+ .tool { margin: 8px 0 0; padding: 8px 0 0; border-top: 1px solid var(--line) }
163
+ .tool-name { margin: 0; color: var(--blue); font-size: 12px }
164
+ .tool-args, .tool-result { margin: 4px 0 0; padding: 8px; background: var(--bg); border-left: 1px solid var(--line) }
165
+ .tool-result { color: var(--muted) }
166
+ .to-top { display: block; margin: 24px 0 0; text-align: right; font-size: 12px }
167
+ footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px }
168
+ /* 窄屏:只保证不溢出,不做响应式框架。时间/提案列让位给内容,操作列可以换行 */
169
+ @media (max-width: 900px) {
170
+ .wrap { padding: 0 8px 16px }
171
+ .rows .time, .rows .delta { display: none }
172
+ .rows .status { width: 72px }
173
+ .rows td, .rows th { padding: 6px }
174
+ .rows .ops { white-space: normal }
175
+ }
176
+ /* 宽屏才给左侧常驻目录:窄屏用顶部折叠目录兜底(在 render-session.ts 里) */
177
+ @media (min-width: 1200px) {
178
+ .ses-wrap { flex-direction: row }
179
+ .toc { display: block; position: sticky; top: 0; align-self: flex-start; width: 260px;
180
+ max-height: calc(100vh - 80px); overflow: auto; font-size: 12px }
181
+ .toc a { display: block; padding: 2px 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
182
+ .toc a:hover { color: var(--blue); text-decoration: none }
183
+ .ses-body { flex: 1; min-width: 0 }
184
+ }
185
+ details.toc-fold { display: block; margin: 0 0 12px; font-size: 12px }
186
+ details.toc-fold > summary { cursor: pointer; color: var(--muted) }
187
+ details.toc-fold .toc-list { padding: 8px 0 0 8px }
188
+ @media (min-width: 1200px) { details.toc-fold { display: none } }
124
189
  `.trim();
@@ -43,6 +43,63 @@ export function shortRunId(runId: string): string {
43
43
  return runId.length > 16 && runId.includes("-") ? `${runId.slice(0, 8)}…${runId.slice(-4)}` : runId;
44
44
  }
45
45
 
46
+ /**
47
+ * 列表「操作」列里的 runId:只留末 8 位(真实数据的 9 条 run 前 8 位完全相同,
48
+ * 保留前缀等于没显示)。完整 id 交给链接的 `title`。
49
+ */
50
+ export function tailRunId(runId: string): string {
51
+ return runId.length > 8 ? `…${runId.slice(-8)}` : runId;
52
+ }
53
+
54
+ /** 同一个本地日期(列表「今天只写时分」的判据) */
55
+ function sameDay(a: Date, b: Date): boolean {
56
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
57
+ }
58
+
59
+ const pad2 = (value: number): string => String(value).padStart(2, "0");
60
+
61
+ /** `HH:mm`:会话页每条消息的时钟 */
62
+ export function formatTime(ts: number): string {
63
+ const date = new Date(ts);
64
+ return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
65
+ }
66
+
67
+ /** `HH:mm:ss`:消息流抬头用(同一分钟内的多条要能分先后) */
68
+ export function formatTimeSeconds(ts: number | null): string {
69
+ if (ts === null || !Number.isFinite(ts)) return "—";
70
+ const date = new Date(ts);
71
+ return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
72
+ }
73
+
74
+ /**
75
+ * 列表「时间」列:今天只写 `19:35`,更早的写 `11-07 18:24`。
76
+ * 一列里全是完整日期时,今天那几条反而最难扫(要对比年月日才知道是刚才还是前天)。
77
+ */
78
+ export function formatClock(ts: number | null, now: number): string {
79
+ if (ts === null || !Number.isFinite(ts)) return "—";
80
+ const date = new Date(ts);
81
+ const clock = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
82
+ if (sameDay(date, new Date(now))) return clock;
83
+ return `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${clock}`;
84
+ }
85
+
86
+ /** 完整时刻放 `title`:悬停要能看到可复制、可跨天比对的绝对时间 */
87
+ export function formatIso(ts: number | null): string {
88
+ if (ts === null || !Number.isFinite(ts)) return "—";
89
+ return new Date(ts).toISOString();
90
+ }
91
+
92
+ /** 会话页概要里的时间段:同一天写 `10:32 → 11:52`,跨天把日期带上 */
93
+ export function formatSpan(start: number | null, end: number | null): string {
94
+ if (start === null || end === null) return "—";
95
+ const first = new Date(start);
96
+ const last = new Date(end);
97
+ const clock = (date: Date): string => `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
98
+ if (sameDay(first, last)) return `${clock(first)} → ${clock(last)}`;
99
+ const day = (date: Date): string => `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${clock(date)}`;
100
+ return `${day(first)} → ${day(last)}`;
101
+ }
102
+
46
103
  /**
47
104
  * 详情第一行永远是完整 runId:列表上显示的是缩写,复制要靠这一行。
48
105
  * 文案里带 `runId:` 前缀是因为详情区是纯文本,没有别的办法标出这一行是什么。
@@ -0,0 +1,162 @@
1
+ /**
2
+ * 会话页快速导航(用户 2026-11-08:「会话缺少快速导航,一直往下滚动太累了」)的验收测试。
3
+ *
4
+ * 口径:目录与锚点由**服务端一次遍历**产出(不引 JS,不做滚动高亮);
5
+ * 宽屏左侧常驻、窄屏顶部折叠是同一份 HTML 的两种 CSS,所以两处内容必须一模一样。
6
+ *
7
+ * 纪律:`SessionSource` 一律由本文件现造的 JSONL 解析而来,不读真实 sessions 目录。
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import type { WebState } from "../lib/web/data.ts";
13
+ import { renderSessionPage } from "../lib/web/render-session.ts";
14
+ import { parseSessionText } from "../lib/web/session-view.ts";
15
+ import type { SessionSource } from "../lib/web/session-file.ts";
16
+
17
+ const SID = "01a1b90f-08c3-722c-871d-d024b083770a";
18
+ const FILE = `/tmp/${SID}.jsonl`;
19
+
20
+ function line(record: unknown): string {
21
+ return `${JSON.stringify(record)}\n`;
22
+ }
23
+
24
+ function user(text: string, at: number): string {
25
+ return line({
26
+ type: "message",
27
+ timestamp: new Date(at).toISOString(),
28
+ message: { role: "user", content: [{ type: "text", text }] },
29
+ });
30
+ }
31
+
32
+ function assistant(text: string, calls: { id: string; name: string; arguments?: unknown }[] = []): string {
33
+ const content: unknown[] = [];
34
+ if (text.length > 0) content.push({ type: "text", text });
35
+ for (const call of calls) content.push({ type: "toolCall", id: call.id, name: call.name, arguments: call.arguments ?? {} });
36
+ return line({ type: "message", message: { role: "assistant", content } });
37
+ }
38
+
39
+ function result(toolCallId: string, text: string): string {
40
+ return line({
41
+ type: "message",
42
+ message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }] },
43
+ });
44
+ }
45
+
46
+ /** 三条用户消息 + 两段工具调用(够测导航、锚点与「只看对话」) */
47
+ function jsonl(): string {
48
+ const t = (hour: number, minute: number) => new Date(2026, 10, 8, hour, minute).getTime();
49
+ return (
50
+ user("第一段:先看列表页", t(10, 32)) +
51
+ assistant("", [{ id: "c1", name: "read", arguments: { path: "render.ts" } }]) +
52
+ result("c1", "ok") +
53
+ user("第二段:再改样式", t(11, 5)) +
54
+ assistant("", [
55
+ { id: "c2", name: "bash", arguments: { command: "npm test" } },
56
+ { id: "c3", name: "bash", arguments: { command: "npm run lint" } },
57
+ ]) +
58
+ result("c2", "ok") +
59
+ result("c3", "ok") +
60
+ user("第三段:收尾", t(11, 52)) +
61
+ assistant("好了")
62
+ );
63
+ }
64
+
65
+ function emptyState(): WebState {
66
+ return {
67
+ pending: [],
68
+ approvals: [],
69
+ applied: [],
70
+ done: [],
71
+ reports: [],
72
+ summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
73
+ };
74
+ }
75
+
76
+ function source(raw = jsonl()): SessionSource {
77
+ return { sessionId: SID, file: FILE, sizeBytes: Buffer.byteLength(raw, "utf8"), transcript: parseSessionText(raw) };
78
+ }
79
+
80
+ function page(options: { onlyUser?: boolean } = {}): string {
81
+ return renderSessionPage(source(), { state: emptyState(), onlyUser: options.onlyUser === true });
82
+ }
83
+
84
+ /** 取 aside 里的目录链接(宽屏那份) */
85
+ function tocLinks(html: string): string[] {
86
+ const aside = /<aside class="toc">([\s\S]*?)<\/aside>/.exec(html)?.[1] ?? "";
87
+ return [...aside.matchAll(/<a href="#(m-\d+)">/g)].map((match) => match[1] ?? "");
88
+ }
89
+
90
+ test("目录条目数等于渲染出的用户消息数(用户消息就是天然章节)", () => {
91
+ const html = page();
92
+ assert.equal(tocLinks(html).length, 3);
93
+ assert.match(html, /<a href="#m-1">10:32 · 第一段:先看列表页<\/a>/);
94
+ });
95
+
96
+ test("锚点与目录一一对应:id 唯一、href 全都能落到实处", () => {
97
+ const html = page();
98
+ const ids = [...html.matchAll(/<div class="msg user m" id="(m-\d+)">/g)].map((match) => match[1]);
99
+ assert.deepEqual(ids, ["m-1", "m-3", "m-5"], "三条用户消息的块号(工具块不占 msg 块号)");
100
+ assert.deepEqual(new Set(ids).size, ids.length, "id 不许重复");
101
+ assert.deepEqual(tocLinks(html), ids, "目录指向的正是这三块");
102
+ });
103
+
104
+ test("窄屏折叠目录:同一份内容放在顶部 <details> 里(宽屏由 CSS 隐藏)", () => {
105
+ const html = page();
106
+ assert.match(html, /<details class="toc-fold"><summary>目录(3 条用户消息)<\/summary>/);
107
+ const fold = /<div class="toc-list">([\s\S]*?)<\/div>/.exec(html)?.[1] ?? "";
108
+ assert.deepEqual([...fold.matchAll(/<a href="#(m-\d+)">/g)].map((match) => match[1]), tocLinks(html));
109
+ });
110
+
111
+ test("目录文本:转义且截到 40 字(长标题不把侧栏撑成两行)", () => {
112
+ const long = `${"很".repeat(60)}<b>x</b>`;
113
+ const html = renderSessionPage(
114
+ { sessionId: SID, file: FILE, sizeBytes: 10, transcript: parseSessionText(user(long, new Date(2026, 10, 8, 9, 0).getTime())) },
115
+ { state: emptyState() },
116
+ );
117
+ const aside = /<aside class="toc">([\s\S]*?)<\/aside>/.exec(html)?.[1] ?? "";
118
+ assert.match(aside, new RegExp(`09:00 · 很{40}…`));
119
+ assert.equal(aside.includes("<b>"), false, "目录文本必须转义");
120
+ });
121
+
122
+ test("只看对话:工具块全部省略成一行,且给「看全部」的回头路", () => {
123
+ const html = page({ onlyUser: true });
124
+ assert.equal(html.includes('<details class="tools">'), false);
125
+ assert.match(html, /<p class="hint">… 本轮省略 1 次工具调用/);
126
+ assert.match(html, /<p class="hint">… 本轮省略 2 次工具调用(<a href="[^"]+">看全部<\/a>)<\/p>/);
127
+ const back = /<a href="([^"]+)">看全部<\/a>/.exec(html)?.[1] ?? "";
128
+ assert.ok(back.includes(`sessionId=${SID}`), "回头路必须还在这个会话");
129
+ assert.equal(back.includes("only=user"), false, "看全部 = 去掉 only=user");
130
+ });
131
+
132
+ test("看全部 ⇄ 只看对话:切换链接都带着 sessionId 与原文件路径", () => {
133
+ const all = page();
134
+ const toOnly = /<a href="([^"]+)">只看对话<\/a>/.exec(all)?.[1] ?? "";
135
+ assert.ok(toOnly.includes("only=user"));
136
+ assert.ok(toOnly.includes(encodeURIComponent(FILE)), "换机器时 file 参数是找回原文的关键");
137
+ const only = page({ onlyUser: true });
138
+ assert.match(only, /<span class="ov-link"><a href="[^"]*">看全部<\/a>(当前只看对话)<\/span>/);
139
+ });
140
+
141
+ test("两个跳转:顶部「跳到最新 ↓」指向最后一块,底部「↑ 回到顶部」指回 #top", () => {
142
+ const html = page();
143
+ // 最后一块是收尾的助手消息(m-6):跳转要落在它身上,不能落在工具块上
144
+ assert.match(html, /<a class="to-end" href="#m-6">跳到最新 ↓<\/a>/);
145
+ assert.match(html, /<a class="to-top" href="#top">↑ 回到顶部<\/a>/);
146
+ assert.match(html, /<div class="wrap" id="top">/);
147
+ });
148
+
149
+ test("没有用户消息时目录给一句人话;没有任何块时也不给「跳到最新」", () => {
150
+ const onlyAssistant = renderSessionPage(
151
+ { sessionId: SID, file: FILE, sizeBytes: 10, transcript: parseSessionText(assistant("只有助手在自言自语")) },
152
+ { state: emptyState() },
153
+ );
154
+ assert.match(onlyAssistant, /这段会话里没有用户消息/);
155
+
156
+ const nothing = renderSessionPage(
157
+ { sessionId: SID, file: FILE, sizeBytes: 0, transcript: parseSessionText("") },
158
+ { state: emptyState() },
159
+ );
160
+ assert.equal(nothing.includes('class="to-end"'), false, "没有块可跳就不给「跳到最新」");
161
+ assert.match(nothing, /没有可渲染的对话/);
162
+ });
@@ -148,14 +148,19 @@ test("readSessionSource:非法 sessionId 直接返回空,不扫盘", () => {
148
148
  assert.deepEqual([source.file, source.transcript], [null, null]);
149
149
  });
150
150
 
151
- test("renderSessionPage:顶部给完整 sessionId 与文件路径,底部给原文件统计,工具块默认收起", () => {
151
+ test("renderSessionPage:顶部一行给返回入口 / 会话短 id / 原文件,概览与出口给统计,工具块默认收起", () => {
152
152
  const { home, file } = fakeHome();
153
153
  const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: file, status: "rejected" });
154
154
  const source = readSessionSource({ evolutionDir, sessionId: SESSION_ID, home });
155
155
  const html = renderSessionPage(source, { state: emptyState() });
156
- assert.match(html, new RegExp(`<code>${SESSION_ID}</code>`));
157
- assert.match(html, /<code>.*2026-11-08T00-00-00-000Z_.*\.jsonl<\/code>/);
158
- assert.match(html, /原文件 <code>.*\.jsonl<\/code>(3 条消息 \/ 1 次工具调用 \/ \d+B)/);
156
+ // 顶部一行:回来有路、会话只显示前 8 位(完整 id 在 title 里,9 条 run 的前缀全一样)
157
+ assert.match(html, /<a href="\/\?view=pending&amp;range=all">← 返回待处理<\/a>/);
158
+ assert.match(html, /<span class="ses-id">会话 <code>01a1b90f<\/code><\/span>/);
159
+ assert.match(html, /原文件 <code title="[^"]*2026-11-08T00-00-00-000Z_.*\.jsonl">/);
160
+ // 概览:消息 / 工具调用 / 大小(时间跨度只在这段会话有 timestamp 时才出现)
161
+ assert.match(html, /<p class="ov">消息 3 · 工具调用 1 · \d+B/);
162
+ // 出口:渲染了多少条、原文件在哪
163
+ assert.match(html, /已渲染全部 3 条消息(原文件 <code>.*\.jsonl<\/code>)/);
159
164
  assert.equal(/<details[^>]*\sopen/.test(html), false, "工具块默认收起");
160
165
  assert.match(html, /看一下这个文件/);
161
166
  });
@@ -184,8 +189,9 @@ test("HTTP 路由:GET /?view=session&sessionId= 接对了(HOME 指到临时
184
189
  assert.equal(response.status, 200);
185
190
  assert.match(response.headers.get("content-type") ?? "", /text\/html/);
186
191
  const html = await response.text();
187
- assert.match(html, /工具调用:read ×1(点击展开)/);
192
+ assert.match(html, /<details class="tools"><summary>▸ read ×1 · <span class="muted">/);
188
193
  assert.equal(/<details[^>]*\sopen/.test(html), false);
194
+ assert.match(html, /← 返回待处理/);
189
195
  } finally {
190
196
  await result.panel.close();
191
197
  if (original === undefined) delete process.env.HOME;