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,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,142 @@
1
+ /**
2
+ * 待处理四类徽章的验收测试(用户 2026-11-08:「待入队是什么意思」暴露的两个误标)。
3
+ *
4
+ * 两条口径:
5
+ * 1. **写了 run-error.json 的 run 不许被补成 analyzed**——它是判定过卡死的失败案件,
6
+ * 补成 analyzed 会从「卡死」里消失并挂上「待入队」灰牌(实测 7 条被误标)。
7
+ * 2. `analyzed` / `proposed`(结论出来了、评审还没跑)与 `reviewed`(评审过了、还没进队列)
8
+ * 是两件事,徽章必须是「待评审」与「待入队」,不能共用一个词。
9
+ *
10
+ * 纪律:run 目录一律建在临时目录里,不读写真实 `.pi/evolution`。
11
+ */
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+
18
+ import { listRuns, type RunSummary } from "../lib/runs.ts";
19
+ import { buildPending } from "../lib/web/pending.ts";
20
+ import { pendingRows } from "../lib/web/render-rows.ts";
21
+ import type { SessionRef } from "../lib/web/run-session.ts";
22
+
23
+ const NO_SESSION: SessionRef = { sessionId: null, file: null };
24
+
25
+ function tmpEvolution(): string {
26
+ return fs.mkdtempSync(path.join(os.tmpdir(), "sk-pending-kinds-"));
27
+ }
28
+
29
+ /** 造一条 run:只写 run.json(以及可选的 01-findings.json / run-error.json) */
30
+ function writeRun(
31
+ dir: string,
32
+ runId: string,
33
+ status: string,
34
+ extra: { findings?: boolean; error?: boolean } = {},
35
+ ): void {
36
+ const runDir = path.join(dir, "runs", runId);
37
+ fs.mkdirSync(runDir, { recursive: true });
38
+ fs.writeFileSync(
39
+ path.join(runDir, "run.json"),
40
+ `${JSON.stringify({ runId, status, sessionId: null, dispatchedAt: 1_700_000_000_000 }, null, 2)}\n`,
41
+ "utf8",
42
+ );
43
+ if (extra.findings === true) fs.writeFileSync(path.join(runDir, "01-findings.json"), "{}\n", "utf8");
44
+ if (extra.error === true) {
45
+ fs.writeFileSync(
46
+ path.join(runDir, "run-error.json"),
47
+ `${JSON.stringify({ reason: "child-exited", at: 1, childLogTail: [] }, null, 2)}\n`,
48
+ "utf8",
49
+ );
50
+ }
51
+ }
52
+
53
+ function summaryOf(runId: string, status: string): RunSummary {
54
+ return {
55
+ runId,
56
+ status: status as RunSummary["status"],
57
+ sessionId: null,
58
+ sessionFile: null,
59
+ dispatchedAt: 1_700_000_000_000,
60
+ pid: null,
61
+ supervisorPid: null,
62
+ } as RunSummary;
63
+ }
64
+
65
+ test("写了 run-error.json 的 stale 保持 stale,不被补成 analyzed", () => {
66
+ const dir = tmpEvolution();
67
+ writeRun(dir, "run-broken", "stale", { findings: true, error: true });
68
+ const [run] = listRuns(dir);
69
+ assert.equal(run?.status, "stale");
70
+ });
71
+
72
+ test("有结论、没写 run-error.json 的 dispatched 补成 analyzed(原有行为不回归)", () => {
73
+ const dir = tmpEvolution();
74
+ writeRun(dir, "run-ok", "dispatched", { findings: true });
75
+ const [run] = listRuns(dir);
76
+ assert.equal(run?.status, "analyzed");
77
+ });
78
+
79
+ test("analyzed 归到「待评审」,不是「待入队」", () => {
80
+ const dir = tmpEvolution();
81
+ writeRun(dir, "run-a", "analyzed", { findings: true });
82
+ const items = buildPending([], listRuns(dir), dir);
83
+ const item = items.find((x) => x.runId === "run-a");
84
+ assert.equal(item?.kind, "to-review");
85
+ });
86
+
87
+ test("reviewed 且未入队才算「待入队」", () => {
88
+ const dir = tmpEvolution();
89
+ writeRun(dir, "run-r", "reviewed", { findings: true });
90
+ const items = buildPending([], listRuns(dir), dir);
91
+ assert.equal(items.find((x) => x.runId === "run-r")?.kind, "reviewed");
92
+ });
93
+
94
+ test("徽章文案:待评审 / 待入队 / 卡死 三者不同", () => {
95
+ const rows = pendingRows(
96
+ [
97
+ { runId: "a", kind: "to-review", session: NO_SESSION, skill: "", at: null, detail: "" },
98
+ { runId: "b", kind: "reviewed", session: NO_SESSION, skill: "", at: null, detail: "" },
99
+ { runId: "c", kind: "stale", session: NO_SESSION, skill: "", at: null, detail: "" },
100
+ ],
101
+ { sessionId: null, active: false } as never,
102
+ );
103
+ assert.deepEqual(
104
+ rows.map((row) => row.badge?.text),
105
+ ["待评审", "待入队", "卡死"],
106
+ );
107
+ });
108
+
109
+ test("只有卡死与待审批需要人动手", () => {
110
+ const rows = pendingRows(
111
+ [
112
+ { runId: "a", kind: "to-review", session: NO_SESSION, skill: "", at: null, detail: "" },
113
+ { runId: "b", kind: "reviewed", session: NO_SESSION, skill: "", at: null, detail: "" },
114
+ { runId: "c", kind: "stale", session: NO_SESSION, skill: "", at: null, detail: "" },
115
+ { runId: "d", kind: "approval", session: NO_SESSION, skill: "", at: null, detail: "" },
116
+ ],
117
+ { sessionId: null, active: false } as never,
118
+ );
119
+ assert.deepEqual(
120
+ rows.map((row) => row.needsAction === true),
121
+ [false, false, true, true],
122
+ );
123
+ });
124
+
125
+ test("排序:卡死排在待评审之前(失败优先于排队)", () => {
126
+ const items = [
127
+ { runId: "a", kind: "to-review" as const, session: NO_SESSION, skill: "", at: 10, detail: "" },
128
+ { runId: "c", kind: "stale" as const, session: NO_SESSION, skill: "", at: 5, detail: "" },
129
+ ];
130
+ const dir = tmpEvolution();
131
+ writeRun(dir, "unused", "analyzed", { findings: true });
132
+ // buildPending 自己排序;这里只借它把两个 kind 的排序权重验证出来
133
+ const sorted = [...items].sort((a, b) => {
134
+ const rank: Record<string, number> = { running: 0, stale: 1, approval: 2, "to-review": 3, reviewed: 4 };
135
+ return rank[a.kind]! - rank[b.kind]!;
136
+ });
137
+ assert.deepEqual(
138
+ sorted.map((item) => item.runId),
139
+ ["c", "a"],
140
+ );
141
+ assert.equal(summaryOf("x", "stale").status, "stale");
142
+ });