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.
- package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
- package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
- package/extensions/skill-evolve/lib/web/render-rows.ts +177 -235
- package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
- package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
- package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
- package/extensions/skill-evolve/lib/web/render.ts +116 -54
- package/extensions/skill-evolve/lib/web/server.ts +9 -1
- package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
- package/extensions/skill-evolve/lib/web/shell.ts +42 -11
- package/extensions/skill-evolve/lib/web/style.ts +171 -106
- package/extensions/skill-evolve/lib/web/text.ts +57 -0
- package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
- package/extensions/skill-evolve/test/session-page.test.ts +11 -5
- package/extensions/skill-evolve/test/session-render.test.ts +203 -0
- package/extensions/skill-evolve/test/session-view.test.ts +75 -87
- package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
- package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
- package/extensions/skill-evolve/test/web-render.test.ts +57 -41
- package/extensions/skill-evolve/test/web-server.test.ts +4 -1
- package/extensions/skill-evolve/test/web-session-column.test.ts +30 -32
- package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
- package/package.json +1 -1
|
@@ -1,124 +1,189 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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 {
|
|
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;
|
|
9
|
-
font:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
.
|
|
49
|
-
.
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
|
|
54
|
-
.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
.
|
|
60
|
-
.
|
|
61
|
-
.
|
|
62
|
-
.
|
|
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
|
-
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
|
|
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:
|
|
83
|
-
.kv .v { color:
|
|
113
|
+
.kv .k { color: var(--muted); font-size: 12px }
|
|
114
|
+
.kv .v { color: var(--fg) }
|
|
84
115
|
.kv-line { margin: 4px 0 }
|
|
85
|
-
.
|
|
86
|
-
a.run { color:
|
|
87
|
-
a.
|
|
88
|
-
|
|
89
|
-
/* diff:文件头灰底、hunk 灰字、+ 绿底、-
|
|
90
|
-
.diff { margin: 8px 0 0; padding: 8px 0; background: #0c0e12; border: 1px solid
|
|
91
|
-
overflow-x: auto; white-space: pre; font-size:
|
|
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:
|
|
127
|
+
.diff .hunk { color: var(--muted) }
|
|
100
128
|
.diff .add { background: #12281a; color: #b7e7c5 }
|
|
101
|
-
.diff .del { background: #
|
|
129
|
+
.diff .del { background: #221316; color: #f0bdbd }
|
|
102
130
|
.diff .meta { color: #6f7a8c }
|
|
103
|
-
/* 改后全文:长文件限高内部滚动,别把后面的评审/五闸顶到屏幕外 */
|
|
104
131
|
.candidate { max-height: 480px; overflow: auto }
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
.
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
|
|
113
|
-
.
|
|
114
|
-
|
|
115
|
-
.
|
|
116
|
-
.
|
|
117
|
-
.
|
|
118
|
-
.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
assert.match(html, /<
|
|
158
|
-
assert.match(html,
|
|
156
|
+
// 顶部一行:回来有路、会话只显示前 8 位(完整 id 在 title 里,9 条 run 的前缀全一样)
|
|
157
|
+
assert.match(html, /<a href="\/\?view=pending&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,
|
|
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;
|