@seanmars/tospec 0.23.0 → 0.24.0
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/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
- package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
- package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
- package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
- package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
- package/assets/dashboard/app.js +218 -1180
- package/assets/dashboard/index.html +31 -22
- package/assets/dashboard/lib/charts.js +355 -0
- package/assets/dashboard/lib/derive.js +369 -0
- package/assets/dashboard/lib/dom.js +121 -0
- package/assets/dashboard/lib/routes.js +52 -0
- package/assets/dashboard/lib/store.js +324 -0
- package/assets/dashboard/style.css +3103 -664
- package/assets/dashboard/views/alerts.js +170 -0
- package/assets/dashboard/views/change.js +232 -0
- package/assets/dashboard/views/docs.js +214 -0
- package/assets/dashboard/views/home.js +357 -0
- package/assets/dashboard/views/project.js +168 -0
- package/assets/dashboard/views/session.js +270 -0
- package/assets/dashboard/views/sidebar.js +131 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +74 -95
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/dashboard-relay.d.ts +11 -0
- package/dist/commands/dashboard-relay.d.ts.map +1 -0
- package/dist/commands/dashboard-relay.js +88 -0
- package/dist/commands/dashboard-relay.js.map +1 -0
- package/dist/commands/dashboard.d.ts +62 -102
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +375 -609
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/core/dashboard-hub.d.ts +258 -0
- package/dist/core/dashboard-hub.d.ts.map +1 -0
- package/dist/core/dashboard-hub.js +643 -0
- package/dist/core/dashboard-hub.js.map +1 -0
- package/dist/core/dashboard-routes.d.ts +56 -0
- package/dist/core/dashboard-routes.d.ts.map +1 -0
- package/dist/core/dashboard-routes.js +329 -0
- package/dist/core/dashboard-routes.js.map +1 -0
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +4 -1
- package/dist/core/init.js.map +1 -1
- package/dist/core/local-server.d.ts +1 -1
- package/dist/core/local-server.js +1 -1
- package/dist/core/shared/dashboard-mod.d.ts +24 -0
- package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
- package/dist/core/shared/dashboard-mod.js +44 -0
- package/dist/core/shared/dashboard-mod.js.map +1 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +4 -1
- package/dist/core/update.js.map +1 -1
- package/package.json +3 -2
- package/assets/dashboard/chart.umd.js +0 -14
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// The hub's front page (總覽): every project as one line on a shared stage
|
|
2
|
+
// grid, with the changes that need the user listed above it.
|
|
3
|
+
|
|
4
|
+
import { el, marker, paintLine, icon, lineBadge, note, activityDot } from '../lib/dom.js';
|
|
5
|
+
import {
|
|
6
|
+
STATIONS,
|
|
7
|
+
SIGNAL_LABEL,
|
|
8
|
+
ACTIVITY_LABEL,
|
|
9
|
+
UNKNOWN_STATION,
|
|
10
|
+
sessionStatus,
|
|
11
|
+
sessionDetail,
|
|
12
|
+
projectActivity,
|
|
13
|
+
stationOf,
|
|
14
|
+
stationIndex,
|
|
15
|
+
stationLabel,
|
|
16
|
+
signalOf,
|
|
17
|
+
lineSignal,
|
|
18
|
+
gapOf,
|
|
19
|
+
unreadableInfo,
|
|
20
|
+
idleDays,
|
|
21
|
+
noticeItems,
|
|
22
|
+
staleDaysOf,
|
|
23
|
+
relTime,
|
|
24
|
+
parseLooseDate,
|
|
25
|
+
} from '../lib/derive.js';
|
|
26
|
+
import { store, dataOf, slotOf, loadDetail } from '../lib/store.js';
|
|
27
|
+
import { routes } from '../lib/routes.js';
|
|
28
|
+
import { openSessionPanel } from './session.js';
|
|
29
|
+
|
|
30
|
+
/** Trains whose detail drawer is open; module-level so a live re-render keeps them open. */
|
|
31
|
+
const expanded = new Set();
|
|
32
|
+
|
|
33
|
+
export function renderHome() {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const page = el('div', { class: 'page page-home' });
|
|
36
|
+
|
|
37
|
+
if (!store.projectsLoaded) {
|
|
38
|
+
page.append(el('h1', { class: 'page-title', text: '總覽' }), note(store.hubConnected ? '正在讀取 project 清單.' : '無法連到 dashboard hub.'));
|
|
39
|
+
return page;
|
|
40
|
+
}
|
|
41
|
+
if (store.projects.length === 0) {
|
|
42
|
+
page.append(renderOnboarding());
|
|
43
|
+
return page;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const changeCount = store.projects.reduce((n, p) => n + ((dataOf(p.key).overview || { changes: [] }).changes.length), 0);
|
|
47
|
+
const sessions = store.projects.flatMap((p) => p.sessions);
|
|
48
|
+
const working = sessions.filter((s) => sessionStatus(s) === 'working').length;
|
|
49
|
+
const sub = [`${store.projects.length} 個 project`, `${sessions.length} 個 session`];
|
|
50
|
+
if (working) sub.push(`${working} 個工作中`);
|
|
51
|
+
sub.push(`${changeCount} 個進行中的 change`);
|
|
52
|
+
page.append(
|
|
53
|
+
el('header', { class: 'page-head' }, [
|
|
54
|
+
el('div', { class: 'page-head-text' }, [
|
|
55
|
+
el('h1', { class: 'page-title', text: '總覽' }),
|
|
56
|
+
el('p', { class: 'page-sub num', text: sub.join(' · ') }),
|
|
57
|
+
]),
|
|
58
|
+
legend(),
|
|
59
|
+
])
|
|
60
|
+
);
|
|
61
|
+
page.append(renderNotices(now));
|
|
62
|
+
page.append(renderMap(now));
|
|
63
|
+
return page;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function legend() {
|
|
67
|
+
const items = ['moving', 'call', 'idle', 'stop', 'parked'].map((signal) =>
|
|
68
|
+
el('li', { class: 'legend-item' }, [marker(signal, 12), el('span', { text: SIGNAL_LABEL[signal] })])
|
|
69
|
+
);
|
|
70
|
+
return el('ul', { class: 'legend', 'aria-label': '標記說明' }, items);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderOnboarding() {
|
|
74
|
+
const stations = el('ol', { class: 'onboard-stations' }, STATIONS.map((s) => el('li', {}, [el('span', { class: 'station-dot' }), el('strong', { text: s.label }), el('span', { class: 'muted', text: s.hint })])));
|
|
75
|
+
return el('section', { class: 'onboard' }, [
|
|
76
|
+
el('h1', { class: 'page-title', text: '還沒有 project' }),
|
|
77
|
+
el('p', { class: 'onboard-lead', text: '在任何一個 tospec project 裡啟動 Claude Code session. tospec init 或 tospec update 寫入 .claude/skills/ 的 tospec-dashboard mod 會把 project 註冊到這個 hub, 它的 change 就會出現在總覽上.' }),
|
|
78
|
+
stations,
|
|
79
|
+
el('p', { class: 'muted', text: '每個 change 依檔案狀態落在一個階段: artifact 寫完就進 apply, task 全部勾完就到 sync, sync 通過就能 archive.' }),
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- notices -------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const NOTICE_SIGNAL = { stop: 'stop', call: 'call', idle: 'idle', drift: 'idle' };
|
|
86
|
+
|
|
87
|
+
function renderNotices(now) {
|
|
88
|
+
const items = noticeItems(store.projects, store.data, now);
|
|
89
|
+
const section = el('section', { class: 'notices', 'aria-labelledby': 'notices-title' });
|
|
90
|
+
if (items.length === 0) {
|
|
91
|
+
const anyLoaded = store.projects.some((p) => dataOf(p.key).overview);
|
|
92
|
+
section.classList.add('notices-clear');
|
|
93
|
+
section.append(el('h2', { id: 'notices-title', class: 'notices-title', text: anyLoaded ? '沒有需要處理的 change' : '正在讀取各 project 的快照' }));
|
|
94
|
+
return section;
|
|
95
|
+
}
|
|
96
|
+
section.append(el('h2', { id: 'notices-title', class: 'notices-title' }, ['需要處理', el('span', { class: 'count', text: String(items.length) })]));
|
|
97
|
+
const list = el('ul', { class: 'notice-list' });
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
const slot = slotOf(item.project.key);
|
|
100
|
+
if (item.kind === 'wait') {
|
|
101
|
+
list.append(el('li', { class: 'notice notice-wait' }, [
|
|
102
|
+
activityDot('waiting'),
|
|
103
|
+
lineBadge(item.project.name, slot),
|
|
104
|
+
sessionButton(item.project, item.session, 'notice-subject', [el('span', { text: '等你回覆 · session ' }), el('span', { class: 'mono', text: item.session.id.slice(0, 8) })]),
|
|
105
|
+
el('span', { class: 'notice-text', text: item.text }),
|
|
106
|
+
]));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const subject = item.change
|
|
110
|
+
? el('a', { class: 'notice-subject', href: routes.change(item.project.key, item.change.name), text: item.change.name })
|
|
111
|
+
: item.ticket
|
|
112
|
+
? el('a', { class: 'notice-subject', href: routes.project(item.project.key), text: item.ticket.title || item.ticket.file })
|
|
113
|
+
: el('a', { class: 'notice-subject', href: routes.project(item.project.key), text: '整個 project' });
|
|
114
|
+
list.append(
|
|
115
|
+
el('li', { class: 'notice notice-' + item.kind }, [
|
|
116
|
+
marker(NOTICE_SIGNAL[item.kind], 14),
|
|
117
|
+
lineBadge(item.project.name, slot),
|
|
118
|
+
subject,
|
|
119
|
+
el('span', { class: 'notice-text' }, [item.text, item.hint ? el('span', { class: 'notice-hint', text: item.hint }) : null]),
|
|
120
|
+
])
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
section.append(list);
|
|
124
|
+
return section;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- the map -----------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function renderMap(now) {
|
|
130
|
+
const counts = STATIONS.map(() => 0);
|
|
131
|
+
for (const project of store.projects) {
|
|
132
|
+
const overview = dataOf(project.key).overview;
|
|
133
|
+
if (!overview) continue;
|
|
134
|
+
for (const change of overview.changes) {
|
|
135
|
+
const i = stationIndex(stationOf(change));
|
|
136
|
+
if (i >= 0) counts[i] += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const head = el('div', { class: 'map-head', 'aria-hidden': 'true' }, [
|
|
140
|
+
el('span', { class: 'map-head-label', text: 'project' }),
|
|
141
|
+
...STATIONS.map((s, i) =>
|
|
142
|
+
el('span', { class: 'map-head-station' }, [
|
|
143
|
+
el('strong', { text: s.label }),
|
|
144
|
+
el('span', { class: 'map-head-hint', text: s.hint }),
|
|
145
|
+
// Always present, so every station label sits on the same baseline.
|
|
146
|
+
el('span', { class: 'map-head-count' + (counts[i] ? '' : ' is-zero'), text: String(counts[i]) }),
|
|
147
|
+
])
|
|
148
|
+
),
|
|
149
|
+
]);
|
|
150
|
+
const map = el('section', { class: 'map', 'aria-label': '各 project 的 change 階段' }, [head]);
|
|
151
|
+
for (const project of store.projects) map.append(renderLine(project, now));
|
|
152
|
+
return map;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One project's line; also used, alone, at the top of the project page. */
|
|
156
|
+
export function renderLine(project, now, { asTitle = true } = {}) {
|
|
157
|
+
const data = dataOf(project.key);
|
|
158
|
+
const slot = slotOf(project.key);
|
|
159
|
+
const overview = data.overview;
|
|
160
|
+
const staleDays = staleDaysOf(data);
|
|
161
|
+
const row = paintLine(el('article', { class: 'line-row', 'data-key': project.key }), slot);
|
|
162
|
+
if (!project.online) row.classList.add('is-offline');
|
|
163
|
+
|
|
164
|
+
const trainsAt = STATIONS.map(() => []);
|
|
165
|
+
const depot = [];
|
|
166
|
+
if (overview) {
|
|
167
|
+
for (const change of overview.changes) {
|
|
168
|
+
const i = stationIndex(stationOf(change));
|
|
169
|
+
if (i >= 0) trainsAt[i].push(change);
|
|
170
|
+
else depot.push(change);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const running = project.online && overview && overview.changes.some((c) => signalOf(c, staleDays, now) === 'running');
|
|
174
|
+
if (running) row.classList.add('has-running');
|
|
175
|
+
// A session at work right now: the whole line flows, visible from across the map.
|
|
176
|
+
if (projectActivity(project) === 'working') row.classList.add('is-working');
|
|
177
|
+
|
|
178
|
+
const label = el('header', { class: 'line-label' }, [
|
|
179
|
+
asTitle
|
|
180
|
+
? el('h2', { class: 'line-title' }, [el('a', { href: routes.project(project.key) }, [el('span', { class: 'line-swatch', 'aria-hidden': 'true' }), el('span', { text: project.name })])])
|
|
181
|
+
: null,
|
|
182
|
+
// The project page states the status and lists its sessions in its own sections.
|
|
183
|
+
asTitle ? el('p', { class: 'line-status' + (project.online ? '' : ' is-offline') }, statusText(project, now)) : null,
|
|
184
|
+
asTitle ? sessionList(project, now) : null,
|
|
185
|
+
terminalText(data, now),
|
|
186
|
+
]);
|
|
187
|
+
// Before the line starts: changes whose stage the files cannot give.
|
|
188
|
+
if (depot.length) {
|
|
189
|
+
label.append(el('div', { class: 'depot' }, [
|
|
190
|
+
el('p', { class: 'depot-title', text: '無法判斷階段' }),
|
|
191
|
+
el('ul', { class: 'trains' }, depot.map((change) => renderTrain(project, change, data, staleDays, now))),
|
|
192
|
+
]));
|
|
193
|
+
}
|
|
194
|
+
row.append(label);
|
|
195
|
+
|
|
196
|
+
const stations = el('ol', { class: 'stations' }, [
|
|
197
|
+
el('span', { class: 'track', 'aria-hidden': 'true' }),
|
|
198
|
+
el('span', { class: 'run-segment', 'aria-hidden': 'true' }),
|
|
199
|
+
]);
|
|
200
|
+
STATIONS.forEach((station, i) => {
|
|
201
|
+
const trains = trainsAt[i];
|
|
202
|
+
const li = el('li', { class: 'station' + (trains.length ? ' is-occupied' : ''), 'data-station': station.id }, [
|
|
203
|
+
el('span', { class: 'station-dot', 'aria-hidden': 'true' }),
|
|
204
|
+
el('span', { class: 'sr-only', text: `${station.label}: ${trains.length} 個 change` }),
|
|
205
|
+
]);
|
|
206
|
+
if (trains.length) {
|
|
207
|
+
li.append(el('ul', { class: 'trains' }, trains.map((change) => renderTrain(project, change, data, staleDays, now))));
|
|
208
|
+
}
|
|
209
|
+
stations.append(li);
|
|
210
|
+
});
|
|
211
|
+
row.append(stations);
|
|
212
|
+
|
|
213
|
+
if (data.error) {
|
|
214
|
+
row.append(el('p', { class: 'line-note line-error' }, [marker('stop', 12), el('span', { text: '快照讀取失敗: ' + data.error })]));
|
|
215
|
+
} else if (!overview) {
|
|
216
|
+
row.append(el('p', { class: 'line-note', text: '正在讀取快照.' }));
|
|
217
|
+
} else if (overview.changes.length === 0) {
|
|
218
|
+
row.append(el('p', { class: 'line-note', text: '沒有進行中的 change. 用 tospec new change <name> 建立新的 change.' }));
|
|
219
|
+
}
|
|
220
|
+
return row;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function statusText(project, now) {
|
|
224
|
+
const snapshot = relTime(project.updatedAt, now);
|
|
225
|
+
if (!project.online) return [activityDot('offline'), el('span', { class: 'status-word', text: '離線' }), ` · 最後快照 ${snapshot}`];
|
|
226
|
+
const n = project.sessions.length;
|
|
227
|
+
return [activityDot(projectActivity(project)), `${n} 個 session · 快照 ${snapshot}`];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A button that opens a session's panel: its log, its question, a prompt box. */
|
|
231
|
+
export function sessionButton(project, session, cls, children) {
|
|
232
|
+
const button = el('button', { type: 'button', class: cls, title: `開啟 session ${session.id} 的 log` }, children);
|
|
233
|
+
button.addEventListener('click', () => openSessionPanel(project.key, session.id));
|
|
234
|
+
return button;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Each live session as one line: its state and what it is doing, one click from its log. */
|
|
238
|
+
function sessionList(project, now) {
|
|
239
|
+
if (!project.sessions.length) return null;
|
|
240
|
+
return el('ul', { class: 'line-sessions' }, project.sessions.map((session) => {
|
|
241
|
+
const status = sessionStatus(session);
|
|
242
|
+
const detail = sessionDetail(session, now);
|
|
243
|
+
return el('li', {}, [
|
|
244
|
+
sessionButton(project, session, 'session-chip is-' + status, [
|
|
245
|
+
activityDot(status),
|
|
246
|
+
el('span', { class: 'session-chip-state', text: ACTIVITY_LABEL[status] }),
|
|
247
|
+
detail ? el('span', { class: 'session-chip-detail', text: detail }) : null,
|
|
248
|
+
]),
|
|
249
|
+
]);
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function terminalText(data, now) {
|
|
254
|
+
const archived = data.archive && data.archive.changes;
|
|
255
|
+
if (!archived || archived.length === 0) return null;
|
|
256
|
+
const latest = archived[0];
|
|
257
|
+
const when = parseLooseDate(latest.stamp);
|
|
258
|
+
return el('p', { class: 'line-terminal' }, [`已歸檔 ${archived.length}`, when ? ` · 最近 ${relTime(when, now)}` : '']);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderTrain(project, change, data, staleDays, now) {
|
|
262
|
+
const signal = lineSignal(signalOf(change, staleDays, now), project.online);
|
|
263
|
+
const station = stationOf(change);
|
|
264
|
+
const detail = data.details.get(change.name);
|
|
265
|
+
const id = project.key + '/' + change.name;
|
|
266
|
+
const drawerId = 'train-' + project.key + '-' + change.name.replace(/[^a-z0-9_-]/gi, '_');
|
|
267
|
+
const open = expanded.has(id);
|
|
268
|
+
|
|
269
|
+
const toggle = el('button', {
|
|
270
|
+
type: 'button',
|
|
271
|
+
class: 'train-toggle',
|
|
272
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
273
|
+
'aria-controls': drawerId,
|
|
274
|
+
title: open ? '收合細節' : '展開細節',
|
|
275
|
+
}, [icon('chevron', 14), el('span', { class: 'sr-only', text: '細節' })]);
|
|
276
|
+
|
|
277
|
+
const idle = signal === 'idle' ? ` · 閒置 ${idleDays(change, now)} 天` : '';
|
|
278
|
+
const gap = el('p', { class: 'train-gap' }, [el('span', { class: 'sr-only', text: SIGNAL_LABEL[signal] + ': ' })]);
|
|
279
|
+
if (signal === 'stop') {
|
|
280
|
+
// The parser's reason and the fix ride in the notice list and the drawer;
|
|
281
|
+
// the row keeps to the file and line, each kept whole.
|
|
282
|
+
const summary = unreadableInfo(change).summary;
|
|
283
|
+
const space = summary.indexOf(' ');
|
|
284
|
+
gap.append(
|
|
285
|
+
el('span', { class: 'badge-unreadable', text: '無法解析' }),
|
|
286
|
+
' ',
|
|
287
|
+
el('span', { class: 'keep', text: summary.slice(0, space) }),
|
|
288
|
+
' ',
|
|
289
|
+
el('span', { class: 'keep', text: summary.slice(space + 1) })
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
gap.append(gapOf(change, detail) + idle);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const drawer = el('div', { class: 'train-drawer', id: drawerId }, drawerContent(change, detail, now));
|
|
296
|
+
drawer.hidden = !open;
|
|
297
|
+
|
|
298
|
+
const item = el('li', { class: 'train signal-' + signal, 'data-train': id, 'data-station': station }, [
|
|
299
|
+
el('div', { class: 'train-head' }, [
|
|
300
|
+
marker(signal),
|
|
301
|
+
el('a', { class: 'train-name', href: routes.change(project.key, change.name), text: change.name }),
|
|
302
|
+
toggle,
|
|
303
|
+
]),
|
|
304
|
+
miniRoute(station),
|
|
305
|
+
gap,
|
|
306
|
+
drawer,
|
|
307
|
+
]);
|
|
308
|
+
toggle.addEventListener('click', () => {
|
|
309
|
+
const next = drawer.hidden;
|
|
310
|
+
drawer.hidden = !next;
|
|
311
|
+
toggle.setAttribute('aria-expanded', String(next));
|
|
312
|
+
toggle.title = next ? '收合細節' : '展開細節';
|
|
313
|
+
if (next) expanded.add(id);
|
|
314
|
+
else expanded.delete(id);
|
|
315
|
+
// Only planning trains preload their detail; the drawer fills in when it lands.
|
|
316
|
+
if (next && !detail) loadDetail(project.key, change.name);
|
|
317
|
+
});
|
|
318
|
+
return item;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Narrow layouts lose the shared station grid; each train carries its own four-stop strip. */
|
|
322
|
+
function miniRoute(station) {
|
|
323
|
+
const at = stationIndex(station);
|
|
324
|
+
return el('span', { class: 'mini-route' + (station === UNKNOWN_STATION ? ' is-unknown' : ''), 'aria-hidden': 'true' }, [
|
|
325
|
+
...STATIONS.map((s, i) => el('span', { class: 'mini-stop' + (i === at ? ' is-here' : at >= 0 && i < at ? ' is-passed' : '') })),
|
|
326
|
+
el('span', { class: 'mini-label', text: stationLabel(station) }),
|
|
327
|
+
]);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function drawerContent(change, detail, now) {
|
|
331
|
+
const rows = [];
|
|
332
|
+
if (detail && detail.artifacts.length) {
|
|
333
|
+
rows.push(
|
|
334
|
+
el('ul', { class: 'artifact-chips', 'aria-label': 'artifact' }, detail.artifacts.map((a) =>
|
|
335
|
+
el('li', { class: 'artifact-chip status-' + a.status, title: a.missingDeps ? '等 ' + a.missingDeps.join(', ') : a.status }, [
|
|
336
|
+
el('span', { class: 'chip-dot', 'aria-hidden': 'true' }),
|
|
337
|
+
el('span', { text: a.id }),
|
|
338
|
+
el('span', { class: 'sr-only', text: ' ' + a.status }),
|
|
339
|
+
])
|
|
340
|
+
))
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const { completed, total } = change.taskProgress;
|
|
344
|
+
if (total > 0) {
|
|
345
|
+
const meter = el('span', { class: 'meter', 'aria-hidden': 'true' }, [el('span', { class: 'meter-fill' })]);
|
|
346
|
+
meter.firstChild.style.setProperty('width', Math.round((completed / total) * 100) + '%');
|
|
347
|
+
rows.push(el('p', { class: 'drawer-line' }, [el('span', { class: 'drawer-key', text: 'task' }), meter, el('span', { class: 'num', text: `${completed}/${total}` })]));
|
|
348
|
+
}
|
|
349
|
+
rows.push(el('p', { class: 'drawer-line' }, [el('span', { class: 'drawer-key', text: '更新' }), relTime(change.lastModified, now)]));
|
|
350
|
+
if (change.goal) rows.push(el('p', { class: 'drawer-goal', text: change.goal }));
|
|
351
|
+
if (change.unreadable) {
|
|
352
|
+
const info = unreadableInfo(change);
|
|
353
|
+
rows.push(el('p', { class: 'train-reason', text: info.reason }));
|
|
354
|
+
rows.push(el('p', { class: 'drawer-goal' }, ['修正 ', el('code', { text: info.file }), ' 後, 下一次快照就會恢復.']));
|
|
355
|
+
}
|
|
356
|
+
return rows;
|
|
357
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// One project: its line at full width, its live sessions, the four health
|
|
2
|
+
// charts, and the lists the overview leaves out (specs, tickets, archive).
|
|
3
|
+
|
|
4
|
+
import { el, note, paintLine, icon, activityDot } from '../lib/dom.js';
|
|
5
|
+
import { STATIONS, staleDaysOf, relTime, formatStamp, sessionStatus, sessionDetail, ACTIVITY_LABEL } from '../lib/derive.js';
|
|
6
|
+
import { store, dataOf, slotOf, projectByKey, loadDecisions } from '../lib/store.js';
|
|
7
|
+
import { transitChart, archiveRateChart, decisionTimeline, specSizeChart } from '../lib/charts.js';
|
|
8
|
+
import { routes } from '../lib/routes.js';
|
|
9
|
+
import { renderLine, sessionButton } from './home.js';
|
|
10
|
+
|
|
11
|
+
export function projectHeader(project, active) {
|
|
12
|
+
const data = dataOf(project.key);
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const decisionCount = data.decisions ? data.decisions.length : null;
|
|
15
|
+
const head = paintLine(el('header', { class: 'project-head' }), slotOf(project.key));
|
|
16
|
+
head.append(
|
|
17
|
+
el('h1', { class: 'page-title project-title' }, [el('span', { class: 'line-swatch', 'aria-hidden': 'true' }), el('span', { text: project.name })]),
|
|
18
|
+
el('p', { class: 'project-root mono', text: project.root, title: project.root }),
|
|
19
|
+
el('p', { class: 'project-status' + (project.online ? '' : ' is-offline') }, project.online
|
|
20
|
+
? [el('span', { class: 'status-dot', 'aria-hidden': 'true' }), `${project.sessions.length} 個 session 連線中 · 快照 ${relTime(project.updatedAt, now)}`]
|
|
21
|
+
: [el('span', { class: 'status-word', text: '離線' }), ` · 最後快照 ${relTime(project.updatedAt, now)}. 沒有 session 時只能讀快照; 文件內容與勾 task 要等 session 回來.`]),
|
|
22
|
+
el('nav', { class: 'subnav', 'aria-label': 'project' }, [
|
|
23
|
+
el('a', { href: routes.project(project.key), class: 'subnav-link', 'aria-current': active === 'overview' ? 'page' : null, text: '總覽' }),
|
|
24
|
+
el('a', { href: routes.decisions(project.key), class: 'subnav-link', 'aria-current': active === 'decisions' ? 'page' : null }, ['Decisions', decisionCount != null ? el('span', { class: 'count', text: String(decisionCount) }) : null]),
|
|
25
|
+
])
|
|
26
|
+
);
|
|
27
|
+
return head;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderProject(route) {
|
|
31
|
+
const project = projectByKey(route.key);
|
|
32
|
+
if (!project) return unknownProject(route.key);
|
|
33
|
+
const data = dataOf(project.key);
|
|
34
|
+
if (!data.decisions && !data.decisionsError) loadDecisions(project.key);
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const page = paintLine(el('div', { class: 'page page-project' }), slotOf(project.key));
|
|
37
|
+
page.append(projectHeader(project, 'overview'));
|
|
38
|
+
|
|
39
|
+
const map = el('section', { class: 'map map-single', 'aria-label': project.name + ' 的 change 階段' }, [
|
|
40
|
+
el('div', { class: 'map-head', 'aria-hidden': 'true' }, [el('span', { class: 'map-head-label' }), ...STATIONS.map((s) => el('span', { class: 'map-head-station' }, [el('strong', { text: s.label }), el('span', { class: 'map-head-hint', text: s.hint })]))]),
|
|
41
|
+
renderLine(project, now, { asTitle: false }),
|
|
42
|
+
]);
|
|
43
|
+
page.append(map);
|
|
44
|
+
page.append(sessionsSection(project, now));
|
|
45
|
+
|
|
46
|
+
const overview = data.overview;
|
|
47
|
+
if (!overview) {
|
|
48
|
+
page.append(note(data.error ? '快照讀取失敗: ' + data.error : '正在讀取快照.'));
|
|
49
|
+
return page;
|
|
50
|
+
}
|
|
51
|
+
if (data.activity && data.activity.available && data.activity.specDrift) {
|
|
52
|
+
page.append(el('p', { class: 'drift-note', text: '近 7 天程式碼有變動, tospec/ 沒有. 實作可能已經走在 spec 前面.' }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const charts = el('section', { class: 'charts', 'aria-label': '圖表' }, [
|
|
56
|
+
transitChart(overview.changes, staleDaysOf(data), now),
|
|
57
|
+
archiveRateChart((data.archive && data.archive.changes) || [], now),
|
|
58
|
+
data.decisions
|
|
59
|
+
? decisionTimeline(project.key, data.decisions, now)
|
|
60
|
+
: el('figure', { class: 'chart chart-empty' }, [el('figcaption', { class: 'chart-caption' }, [el('h3', { class: 'chart-title', text: 'ADR 時間軸' })]), note(data.decisionsError ? '決策讀取失敗: ' + data.decisionsError : '正在讀取決策.')]),
|
|
61
|
+
specSizeChart(project.key, overview.specs),
|
|
62
|
+
]);
|
|
63
|
+
page.append(charts);
|
|
64
|
+
|
|
65
|
+
page.append(
|
|
66
|
+
el('div', { class: 'project-lists' }, [
|
|
67
|
+
specsList(project.key, overview.specs),
|
|
68
|
+
ticketsList(project.key, overview.tickets),
|
|
69
|
+
archiveList(data.archive, now),
|
|
70
|
+
])
|
|
71
|
+
);
|
|
72
|
+
return page;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unknownProject(key) {
|
|
76
|
+
return el('div', { class: 'page' }, [
|
|
77
|
+
el('h1', { class: 'page-title', text: '找不到這個 project' }),
|
|
78
|
+
note(store.projectsLoaded ? `hub 上沒有 key 為 ${key} 的 project. 它的 session 可能還沒向重新啟動的 hub 註冊.` : '正在讀取 project 清單.'),
|
|
79
|
+
el('p', {}, [el('a', { href: routes.home(), text: '回到總覽' })]),
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function listSection(title, count, body) {
|
|
84
|
+
return el('section', { class: 'list-section' }, [
|
|
85
|
+
el('h2', { class: 'section-title' }, [title, count != null ? el('span', { class: 'count', text: String(count) }) : null]),
|
|
86
|
+
body,
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function specsList(key, specs) {
|
|
91
|
+
if (!specs.length) return listSection('Specs', 0, note('還沒有 spec.'));
|
|
92
|
+
return listSection('Specs', specs.length, el('ul', { class: 'plain-list' }, specs.map((s) =>
|
|
93
|
+
el('li', { class: 'row' }, [el('a', { class: 'row-name', href: routes.spec(key, s.id), text: s.id }), el('span', { class: 'row-meta num', text: `${s.requirementCount} 條 requirement` })])
|
|
94
|
+
)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ticketsList(key, tickets) {
|
|
98
|
+
if (!tickets.length) return listSection('Tickets', 0, note('沒有進行中的 ticket.'));
|
|
99
|
+
return listSection('Tickets', tickets.length, el('ul', { class: 'plain-list' }, tickets.map((t) =>
|
|
100
|
+
el('li', { class: 'row ticket-row' + (t.parseError ? ' row-error' : '') }, [
|
|
101
|
+
el('span', { class: 'row-time mono', text: formatStamp(t.date) }),
|
|
102
|
+
el('span', { class: 'row-name', text: t.title }),
|
|
103
|
+
t.type ? el('span', { class: 'tag', text: t.type }) : null,
|
|
104
|
+
t.change ? el('a', { class: 'ticket-change', href: routes.change(key, t.change), text: t.change }) : null,
|
|
105
|
+
t.parseError
|
|
106
|
+
? el('span', { class: 'badge-unreadable', text: 'frontmatter 無法解析' })
|
|
107
|
+
: t.summary ? el('span', { class: 'ticket-summary', text: t.summary }) : null,
|
|
108
|
+
])
|
|
109
|
+
)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every live session by its full id, so the person can tell which terminal is
|
|
114
|
+
* which (Claude Code shows the id in `/status`), with its state beside it.
|
|
115
|
+
*/
|
|
116
|
+
function sessionsSection(project, now) {
|
|
117
|
+
const section = el('section', { class: 'sessions-board', 'aria-labelledby': 'sessions-title' }, [
|
|
118
|
+
el('h2', { id: 'sessions-title', class: 'section-title' }, ['Sessions', el('span', { class: 'count', text: String(project.sessions.length) })]),
|
|
119
|
+
]);
|
|
120
|
+
if (!project.sessions.length) {
|
|
121
|
+
section.append(note('沒有 Claude Code session 連線, 畫面是最後一次快照.'));
|
|
122
|
+
return section;
|
|
123
|
+
}
|
|
124
|
+
section.append(el('ul', { class: 'plain-list session-list' }, project.sessions.map((s) => sessionRow(project, s, now))));
|
|
125
|
+
return section;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sessionRow(project, session, now) {
|
|
129
|
+
const status = sessionStatus(session);
|
|
130
|
+
const copy = el('button', { type: 'button', class: 'icon-button', title: '複製 session id' }, [icon('copy', 14), el('span', { class: 'sr-only', text: '複製 session id' })]);
|
|
131
|
+
copy.addEventListener('click', async () => {
|
|
132
|
+
try {
|
|
133
|
+
await navigator.clipboard.writeText(session.id);
|
|
134
|
+
copy.title = '已複製';
|
|
135
|
+
} catch {
|
|
136
|
+
copy.title = '無法複製, 請手動選取';
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const detail = sessionDetail(session, now);
|
|
140
|
+
return el('li', { class: 'session-row is-' + status }, [
|
|
141
|
+
el('span', { class: 'session-state' }, [activityDot(status), el('span', { text: ACTIVITY_LABEL[status] })]),
|
|
142
|
+
el('span', { class: 'session-main' }, [
|
|
143
|
+
el('span', { class: 'session-id mono', text: session.id }),
|
|
144
|
+
detail ? el('span', { class: 'session-detail', text: detail }) : null,
|
|
145
|
+
]),
|
|
146
|
+
// Always a cell, so every row's time and buttons line up in their columns.
|
|
147
|
+
session.id === project.updatedBy ? el('span', { class: 'tag tag-strong', text: '目前快照' }) : el('span'),
|
|
148
|
+
el('span', { class: 'row-time', text: '連線於 ' + relTime(session.connectedAt, now) }),
|
|
149
|
+
el('span', { class: 'session-actions' }, [
|
|
150
|
+
copy,
|
|
151
|
+
sessionButton(project, session, 'button button-small', [icon('log', 14), el('span', { text: status === 'waiting' ? '回覆' : 'Log' })]),
|
|
152
|
+
]),
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function archiveList(archive, now) {
|
|
157
|
+
const changes = (archive && archive.changes) || [];
|
|
158
|
+
if (!changes.length) return listSection('Archive', 0, note('還沒有歸檔的 change.'));
|
|
159
|
+
const LIMIT = 12;
|
|
160
|
+
const list = el('ul', { class: 'plain-list' }, changes.slice(0, LIMIT).map((c) =>
|
|
161
|
+
el('li', { class: 'row archive-row' }, [
|
|
162
|
+
el('span', { class: 'row-name', text: c.name || c.dirName }),
|
|
163
|
+
el('span', { class: 'row-time mono', text: formatStamp(c.stamp) }),
|
|
164
|
+
])
|
|
165
|
+
));
|
|
166
|
+
const more = changes.length > LIMIT ? note(`另有 ${changes.length - LIMIT} 個較早的歸檔, 在 tospec/changes/archive/.`) : null;
|
|
167
|
+
return listSection('Archive', changes.length, el('div', {}, [list, more]));
|
|
168
|
+
}
|