@seanmars/tospec 0.22.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 +54 -0
- package/README.md +22 -6
- 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/schemas/decision/schema.yaml +1 -1
- package/schemas/issue/schema.yaml +4 -4
- package/schemas/sdd/schema.yaml +4 -3
- package/assets/dashboard/chart.umd.js +0 -14
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Tells the person when a session needs them (it waits on a question) or has
|
|
2
|
+
// finished (its turn ended and it idles): a toast in the page, and a browser
|
|
3
|
+
// notification when the page is not in front. Either one, clicked, opens the
|
|
4
|
+
// project and that session's panel.
|
|
5
|
+
|
|
6
|
+
import { el, icon, activityDot, lineBadge } from '../lib/dom.js';
|
|
7
|
+
import { sessionAlerts, askSummary } from '../lib/derive.js';
|
|
8
|
+
import { store, slotOf } from '../lib/store.js';
|
|
9
|
+
import { routes, parseRoute } from '../lib/routes.js';
|
|
10
|
+
import { openSessionPanel, isSessionPanelOpen } from './session.js';
|
|
11
|
+
|
|
12
|
+
const NOTIFY_KEY = 'tospec-notify';
|
|
13
|
+
/** A finished turn is passing news; a question stays until it is answered or dismissed. */
|
|
14
|
+
const IDLE_TOAST_MS = 10_000;
|
|
15
|
+
const TOAST_LIMIT = 4;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* State last seen per session; null until the first project list arrives, so
|
|
19
|
+
* whatever was already true when the page opened is not announced as news.
|
|
20
|
+
*/
|
|
21
|
+
let seen = null;
|
|
22
|
+
/** `<key>/<session>` -> { node, kind, timer } */
|
|
23
|
+
const toasts = new Map();
|
|
24
|
+
|
|
25
|
+
function toastHost() {
|
|
26
|
+
return document.getElementById('toasts');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Run after every store change: announces what changed since the last run. */
|
|
30
|
+
export function checkSessionAlerts() {
|
|
31
|
+
if (!store.projectsLoaded) return;
|
|
32
|
+
const first = seen === null;
|
|
33
|
+
const result = sessionAlerts(seen || new Map(), store.projects);
|
|
34
|
+
seen = result.seen;
|
|
35
|
+
// A question no longer open (answered, here or in the terminal) takes its toast with it.
|
|
36
|
+
for (const [id, toast] of toasts) {
|
|
37
|
+
if (toast.kind === 'wait' && !String(seen.get(id) || '').startsWith('waiting')) dismiss(id);
|
|
38
|
+
}
|
|
39
|
+
if (first) return;
|
|
40
|
+
for (const alert of result.alerts) raise(alert);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function idOf(alert) {
|
|
44
|
+
return alert.project.key + '/' + alert.session.id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function raise(alert) {
|
|
48
|
+
const { project, session } = alert;
|
|
49
|
+
// Already looking at this very session: the panel shows it as it happens.
|
|
50
|
+
if (isSessionPanelOpen(project.key, session.id) && document.hasFocus()) return;
|
|
51
|
+
const title = alert.kind === 'wait' ? '等你回覆' : '已閒置';
|
|
52
|
+
const text = alert.kind === 'wait'
|
|
53
|
+
? askSummary(session.ask)
|
|
54
|
+
: `session ${session.id.slice(0, 8)} 的回合結束了.`;
|
|
55
|
+
showToast(alert, title, text);
|
|
56
|
+
notify(alert, title, text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Opens the project's page with the session's panel beside it. */
|
|
60
|
+
function go(alert) {
|
|
61
|
+
const { project, session } = alert;
|
|
62
|
+
const open = () => openSessionPanel(project.key, session.id);
|
|
63
|
+
const target = routes.project(project.key);
|
|
64
|
+
const here = parseRoute(location.hash);
|
|
65
|
+
if (here.view === 'project' && here.key === project.key) {
|
|
66
|
+
open();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// After the router's own hashchange handler, which moves focus to the page:
|
|
70
|
+
// the panel opens last, so the keyboard lands in it.
|
|
71
|
+
window.addEventListener('hashchange', open, { once: true });
|
|
72
|
+
location.hash = target;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function showToast(alert, title, text) {
|
|
76
|
+
const id = idOf(alert);
|
|
77
|
+
dismiss(id);
|
|
78
|
+
const main = el('button', { type: 'button', class: 'toast-main', title: '開啟這個 session' }, [
|
|
79
|
+
el('span', { class: 'toast-head' }, [
|
|
80
|
+
activityDot(alert.kind === 'wait' ? 'waiting' : 'idle'),
|
|
81
|
+
lineBadge(alert.project.name, slotOf(alert.project.key)),
|
|
82
|
+
el('strong', { text: title }),
|
|
83
|
+
]),
|
|
84
|
+
text ? el('span', { class: 'toast-text', text }) : null,
|
|
85
|
+
]);
|
|
86
|
+
main.addEventListener('click', () => {
|
|
87
|
+
dismiss(id);
|
|
88
|
+
go(alert);
|
|
89
|
+
});
|
|
90
|
+
const close = el('button', { type: 'button', class: 'toast-close', title: '關閉' }, [icon('close', 12), el('span', { class: 'sr-only', text: '關閉通知' })]);
|
|
91
|
+
close.addEventListener('click', () => dismiss(id));
|
|
92
|
+
const node = el('div', { class: 'toast toast-' + alert.kind }, [main, close]);
|
|
93
|
+
const host = toastHost();
|
|
94
|
+
host.append(node);
|
|
95
|
+
const timer = alert.kind === 'idle' ? setTimeout(() => dismiss(id), IDLE_TOAST_MS) : null;
|
|
96
|
+
toasts.set(id, { node, kind: alert.kind, timer });
|
|
97
|
+
// The oldest leaves first; an unanswered question outlives a passing idle.
|
|
98
|
+
while (toasts.size > TOAST_LIMIT) {
|
|
99
|
+
const oldest = [...toasts.entries()].find(([, t]) => t.kind === 'idle') || [...toasts.entries()][0];
|
|
100
|
+
dismiss(oldest[0]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function dismiss(id) {
|
|
105
|
+
const toast = toasts.get(id);
|
|
106
|
+
if (!toast) return;
|
|
107
|
+
clearTimeout(toast.timer);
|
|
108
|
+
toast.node.remove();
|
|
109
|
+
toasts.delete(id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- browser notifications ----------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function notificationsOn() {
|
|
115
|
+
return 'Notification' in window && Notification.permission === 'granted' && localStorage.getItem(NOTIFY_KEY) !== 'off';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Only while the page is not in front: in front, the toast already says it,
|
|
120
|
+
* and a system notification on top would say it twice.
|
|
121
|
+
*/
|
|
122
|
+
function notify(alert, title, text) {
|
|
123
|
+
if (!notificationsOn() || (!document.hidden && document.hasFocus())) return;
|
|
124
|
+
const id = idOf(alert);
|
|
125
|
+
const notification = new Notification(`${alert.project.name}: ${title}`, {
|
|
126
|
+
body: text,
|
|
127
|
+
// One per session: a newer state replaces the older notification.
|
|
128
|
+
tag: 'tospec-' + id,
|
|
129
|
+
requireInteraction: alert.kind === 'wait',
|
|
130
|
+
});
|
|
131
|
+
notification.onclick = () => {
|
|
132
|
+
window.focus();
|
|
133
|
+
notification.close();
|
|
134
|
+
dismiss(id);
|
|
135
|
+
go(alert);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The sign bar's notification switch. Permission is asked on the first click,
|
|
141
|
+
* as browsers require a gesture for it; after that the click turns the
|
|
142
|
+
* notifications on and off. Hidden where the browser has no notifications.
|
|
143
|
+
*/
|
|
144
|
+
export function setupNotifyButton(button) {
|
|
145
|
+
if (!('Notification' in window)) {
|
|
146
|
+
button.hidden = true;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const draw = () => {
|
|
150
|
+
const on = notificationsOn();
|
|
151
|
+
const denied = Notification.permission === 'denied';
|
|
152
|
+
button.setAttribute('aria-pressed', String(on));
|
|
153
|
+
button.title = denied
|
|
154
|
+
? '瀏覽器封鎖了這個網站的通知; 請在瀏覽器的網站設定中允許.'
|
|
155
|
+
: on
|
|
156
|
+
? '瀏覽器通知已開啟: session 等你回覆或閒置時, 頁面不在前景就通知. 點一下關閉.'
|
|
157
|
+
: '瀏覽器通知已關閉. 點一下開啟.';
|
|
158
|
+
button.replaceChildren(icon(on ? 'bell' : 'bell-off', 16), el('span', { class: 'btn-text', text: on ? '通知開' : '通知關' }));
|
|
159
|
+
};
|
|
160
|
+
button.addEventListener('click', async () => {
|
|
161
|
+
if (Notification.permission === 'default') {
|
|
162
|
+
const result = await Notification.requestPermission();
|
|
163
|
+
if (result === 'granted') localStorage.setItem(NOTIFY_KEY, 'on');
|
|
164
|
+
} else if (Notification.permission === 'granted') {
|
|
165
|
+
localStorage.setItem(NOTIFY_KEY, notificationsOn() ? 'off' : 'on');
|
|
166
|
+
}
|
|
167
|
+
draw();
|
|
168
|
+
});
|
|
169
|
+
draw();
|
|
170
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// One change: where it stands, its artifacts as stops on an inner line, its
|
|
2
|
+
// tasks (the dashboard's only write), and its files rendered on demand.
|
|
3
|
+
|
|
4
|
+
import { el, note, marker, paintLine, icon } from '../lib/dom.js';
|
|
5
|
+
import { STATIONS, SIGNAL_LABEL, stationOf, stationIndex, stationLabel, signalOf, lineSignal, gapOf, unreadableInfo, staleDaysOf, relTime, formatDate, parseLooseDate } from '../lib/derive.js';
|
|
6
|
+
import { dataOf, slotOf, projectByKey, postTask, loadProject, loadDetail } from '../lib/store.js';
|
|
7
|
+
import { routes } from '../lib/routes.js';
|
|
8
|
+
import { renderMarkdown, openFiles } from './docs.js';
|
|
9
|
+
|
|
10
|
+
const ARTIFACT_STATUS = { done: '完成', ready: '可寫', blocked: '等待相依', stub: '待補完' };
|
|
11
|
+
|
|
12
|
+
export function renderChange(route) {
|
|
13
|
+
const project = projectByKey(route.key);
|
|
14
|
+
const data = dataOf(route.key);
|
|
15
|
+
const page = el('div', { class: 'page page-change' });
|
|
16
|
+
if (!project) {
|
|
17
|
+
page.append(el('h1', { class: 'page-title', text: route.name }), note('找不到這個 project.'));
|
|
18
|
+
return page;
|
|
19
|
+
}
|
|
20
|
+
if (!data.overview) {
|
|
21
|
+
page.append(el('h1', { class: 'page-title', text: route.name }), note(data.error ? '快照讀取失敗: ' + data.error : '正在讀取快照.'));
|
|
22
|
+
return page;
|
|
23
|
+
}
|
|
24
|
+
const change = data.overview.changes.find((c) => c.name === route.name);
|
|
25
|
+
const detail = data.details.get(route.name);
|
|
26
|
+
if (!change) {
|
|
27
|
+
page.append(
|
|
28
|
+
el('h1', { class: 'page-title', text: route.name }),
|
|
29
|
+
note('這個 change 不在進行中的清單裡. 它可能已經歸檔, 或已被刪除.'),
|
|
30
|
+
el('p', {}, [el('a', { href: routes.project(route.key), text: '回到 ' + project.name })])
|
|
31
|
+
);
|
|
32
|
+
return page;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const rawSignal = signalOf(change, staleDaysOf(data), now);
|
|
37
|
+
const signal = lineSignal(rawSignal, project.online);
|
|
38
|
+
const station = stationOf(change);
|
|
39
|
+
paintLine(page, slotOf(route.key));
|
|
40
|
+
|
|
41
|
+
page.append(
|
|
42
|
+
el('header', { class: 'change-head' }, [
|
|
43
|
+
el('h1', { class: 'page-title change-title', text: change.name }),
|
|
44
|
+
el('p', { class: 'change-signal signal-' + signal }, [
|
|
45
|
+
marker(signal, 16),
|
|
46
|
+
el('strong', { text: SIGNAL_LABEL[signal] }),
|
|
47
|
+
el('span', { text: ` · ${stationLabel(station)} · ${gapOf(change, detail)}` + (rawSignal === 'running' ? ' · 10 分鐘內有更新' : '') }),
|
|
48
|
+
]),
|
|
49
|
+
project.online
|
|
50
|
+
? null
|
|
51
|
+
: el('p', { class: 'lock-note' }, [icon('lock', 14), `這個 project 沒有 session 連線, 畫面是 ${relTime(project.updatedAt, now)}的快照.`]),
|
|
52
|
+
metaList(route.key, change, detail, now),
|
|
53
|
+
change.goal ? el('p', { class: 'change-goal', text: change.goal }) : null,
|
|
54
|
+
change.unreadable ? stopNote(change) : null,
|
|
55
|
+
])
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
page.append(stationStrip(station));
|
|
59
|
+
const detailError = data.detailErrors && data.detailErrors.get(route.name);
|
|
60
|
+
if (!detail && !detailError) loadDetail(route.key, route.name);
|
|
61
|
+
const detailNote = detailError ? note('change detail 讀取失敗: ' + detailError) : note('正在讀取 artifact 與檔案.');
|
|
62
|
+
page.append(detail ? artifactRoute(detail) : detailNote);
|
|
63
|
+
page.append(taskSection(project, change));
|
|
64
|
+
if (detail) page.append(fileSection(route.key, project, detail));
|
|
65
|
+
return page;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What cannot be parsed, then how to fix it: two lines, never one run-on sentence. */
|
|
69
|
+
function stopNote(change) {
|
|
70
|
+
const info = unreadableInfo(change);
|
|
71
|
+
return el('div', { class: 'change-unreadable signal-stop' }, [
|
|
72
|
+
el('p', {}, [el('span', { class: 'badge-unreadable', text: '無法解析' }), ' ', el('span', { class: 'keep', text: info.summary })]),
|
|
73
|
+
el('p', { class: 'stop-reason', text: change.unreadable }),
|
|
74
|
+
el('p', { class: 'stop-fix' }, ['修正 ', el('code', { text: info.file }), ' 後, 下一次快照就會恢復.']),
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function metaList(key, change, detail, now) {
|
|
79
|
+
const meta = detail ? detail.metadata : {};
|
|
80
|
+
const created = parseLooseDate(meta.created || change.created);
|
|
81
|
+
const items = [
|
|
82
|
+
['schema', meta.schema || change.schema || '未知'],
|
|
83
|
+
meta.type || change.type ? ['type', meta.type || change.type] : null,
|
|
84
|
+
created ? ['created', formatDate(created)] : null,
|
|
85
|
+
['更新', relTime(change.lastModified, now)],
|
|
86
|
+
].filter(Boolean);
|
|
87
|
+
const list = el('dl', { class: 'meta' }, items.flatMap(([k, v]) => [el('dt', { text: k }), el('dd', { text: v })]));
|
|
88
|
+
const decisions = (meta.decisions || []);
|
|
89
|
+
if (decisions.length) {
|
|
90
|
+
list.append(
|
|
91
|
+
el('dt', { text: 'decisions' }),
|
|
92
|
+
el('dd', {}, decisions.flatMap((f, i) => [i ? ', ' : null, el('a', { href: routes.decisions(key, f), text: f })]))
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return list;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The change's place on its project line, with the stations it has passed; none is marked when unknown. */
|
|
99
|
+
function stationStrip(station) {
|
|
100
|
+
const at = stationIndex(station);
|
|
101
|
+
return el('ol', { class: 'station-strip', 'aria-label': '目前階段' }, STATIONS.map((s, i) =>
|
|
102
|
+
el('li', { class: 'strip-stop' + (i === at ? ' is-here' : at >= 0 && i < at ? ' is-passed' : ''), 'aria-current': i === at ? 'step' : null }, [
|
|
103
|
+
el('span', { class: 'strip-dot', 'aria-hidden': 'true' }),
|
|
104
|
+
el('span', { class: 'strip-label', text: s.label }),
|
|
105
|
+
])
|
|
106
|
+
));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Artifacts in schema declaration order. The inner line is drawn straight on
|
|
111
|
+
* purpose: the detail payload names unmet dependencies only, not the full
|
|
112
|
+
* `requires` graph, so a branch drawn here would be a guess.
|
|
113
|
+
*/
|
|
114
|
+
function artifactRoute(detail) {
|
|
115
|
+
if (!detail.artifacts.length) return el('section', { class: 'detail-section' }, [el('h2', { class: 'section-title', text: 'Artifacts' }), note('schema 無法解析, 沒有 artifact 狀態.')]);
|
|
116
|
+
return el('section', { class: 'detail-section' }, [
|
|
117
|
+
el('h2', { class: 'section-title', text: 'Artifacts' }),
|
|
118
|
+
el('ol', { class: 'artifact-route' }, detail.artifacts.map((a) =>
|
|
119
|
+
el('li', { class: 'artifact-stop status-' + a.status }, [
|
|
120
|
+
el('span', { class: 'artifact-dot', 'aria-hidden': 'true' }),
|
|
121
|
+
el('span', { class: 'artifact-id', text: a.id }),
|
|
122
|
+
el('span', { class: 'artifact-state', text: a.status === 'blocked' && a.missingDeps ? '等 ' + a.missingDeps.join(', ') : ARTIFACT_STATUS[a.status] || a.status }),
|
|
123
|
+
])
|
|
124
|
+
)),
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- tasks ---------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function taskSection(project, change) {
|
|
131
|
+
const { completed, total } = change.taskProgress;
|
|
132
|
+
const section = el('section', { class: 'detail-section tasks' });
|
|
133
|
+
const meter = el('span', { class: 'meter meter-wide', 'aria-hidden': 'true' }, [el('span', { class: 'meter-fill' })]);
|
|
134
|
+
meter.firstChild.style.setProperty('width', total ? Math.round((completed / total) * 100) + '%' : '0%');
|
|
135
|
+
section.append(el('div', { class: 'section-head' }, [
|
|
136
|
+
el('h2', { class: 'section-title' }, ['Task', el('span', { class: 'count', text: `${completed}/${total}` })]),
|
|
137
|
+
total ? meter : null,
|
|
138
|
+
]));
|
|
139
|
+
if (!total) {
|
|
140
|
+
section.append(note('這個 change 還沒有 task.'));
|
|
141
|
+
return section;
|
|
142
|
+
}
|
|
143
|
+
// The server enforces both locks too; the page only mirrors them so a click
|
|
144
|
+
// is not offered that would be refused.
|
|
145
|
+
const locked = change.phase === 'archiving';
|
|
146
|
+
const offline = !project.online;
|
|
147
|
+
if (locked) section.append(el('p', { class: 'lock-note' }, [icon('lock', 14), 'sync 已通過, 正在等 archive; task 已凍結.']));
|
|
148
|
+
else if (offline) section.append(el('p', { class: 'lock-note' }, [icon('lock', 14), '沒有 session 連線, 勾選要由 session 寫回檔案, 現在只能閱讀.']));
|
|
149
|
+
|
|
150
|
+
const files = [...new Set(change.tasks.map((t) => t.file))];
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
if (files.length > 1) section.append(el('p', { class: 'task-file mono', text: file }));
|
|
153
|
+
const list = el('ul', { class: 'task-list' });
|
|
154
|
+
for (const task of change.tasks) {
|
|
155
|
+
if (task.file === file) list.append(taskItem(project.key, task, locked || offline));
|
|
156
|
+
}
|
|
157
|
+
section.append(list);
|
|
158
|
+
}
|
|
159
|
+
return section;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One checkbox that writes back to its markdown file through a live session.
|
|
164
|
+
* The file is the source of truth: a failed write reverts the box instead of
|
|
165
|
+
* leaving the page claiming what the file does not say.
|
|
166
|
+
*/
|
|
167
|
+
function taskItem(key, task, disabled) {
|
|
168
|
+
const box = el('input', { type: 'checkbox', class: 'task-box' });
|
|
169
|
+
box.checked = task.done;
|
|
170
|
+
box.disabled = disabled;
|
|
171
|
+
const item = el('li', { class: 'task-item' + (task.done ? ' is-done' : '') + (disabled ? ' is-locked' : '') }, [
|
|
172
|
+
el('label', { class: 'task-label' }, [box, el('span', { class: 'task-text', text: task.text })]),
|
|
173
|
+
]);
|
|
174
|
+
// Mirrors the file's nesting; two spaces of markdown indent read as one step.
|
|
175
|
+
if (task.indent) item.style.setProperty('margin-left', task.indent * 10 + 'px');
|
|
176
|
+
box.addEventListener('change', async () => {
|
|
177
|
+
const want = box.checked;
|
|
178
|
+
box.disabled = true;
|
|
179
|
+
item.classList.add('is-saving');
|
|
180
|
+
item.querySelectorAll('.task-error').forEach((n) => n.remove());
|
|
181
|
+
try {
|
|
182
|
+
await postTask(key, task.file, task.line, want);
|
|
183
|
+
item.classList.toggle('is-done', want);
|
|
184
|
+
await loadProject(key);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
box.checked = !want;
|
|
187
|
+
item.append(el('span', { class: 'task-error', text: '寫入失敗: ' + e.message + '. 檔案沒有變更.' }));
|
|
188
|
+
} finally {
|
|
189
|
+
box.disabled = false;
|
|
190
|
+
item.classList.remove('is-saving');
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return item;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- files ---------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function fileSection(key, project, detail) {
|
|
199
|
+
const section = el('section', { class: 'detail-section files' }, [el('h2', { class: 'section-title' }, ['檔案', el('span', { class: 'count', text: String(detail.files.length) })])]);
|
|
200
|
+
if (!detail.files.length) {
|
|
201
|
+
section.append(note('還沒有檔案.'));
|
|
202
|
+
return section;
|
|
203
|
+
}
|
|
204
|
+
for (const file of detail.files) {
|
|
205
|
+
const details = el('details', { class: 'file' });
|
|
206
|
+
const body = el('div', { class: 'rendered' });
|
|
207
|
+
details.append(
|
|
208
|
+
el('summary', { class: 'file-summary' }, [
|
|
209
|
+
icon('chevron', 14),
|
|
210
|
+
el('span', { class: 'file-path mono', text: file.path }),
|
|
211
|
+
file.artifactId ? el('span', { class: 'tag', text: file.artifactId }) : null,
|
|
212
|
+
]),
|
|
213
|
+
body
|
|
214
|
+
);
|
|
215
|
+
const openKey = key + '|' + file.path;
|
|
216
|
+
const load = () => renderMarkdown(key, project, file.path, body);
|
|
217
|
+
if (openFiles.has(openKey)) {
|
|
218
|
+
details.open = true;
|
|
219
|
+
load();
|
|
220
|
+
}
|
|
221
|
+
details.addEventListener('toggle', () => {
|
|
222
|
+
if (details.open) {
|
|
223
|
+
openFiles.add(openKey);
|
|
224
|
+
load();
|
|
225
|
+
} else {
|
|
226
|
+
openFiles.delete(openKey);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
section.append(details);
|
|
230
|
+
}
|
|
231
|
+
return section;
|
|
232
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Reading views: a spec, the decision index and reader, and the rendered
|
|
2
|
+
// markdown behind change files. Rendering is the one read a snapshot cannot
|
|
3
|
+
// pre-answer, so it needs a live session and is cached until the project's
|
|
4
|
+
// next push.
|
|
5
|
+
|
|
6
|
+
import { el, note, icon, paintLine } from '../lib/dom.js';
|
|
7
|
+
import { formatStamp } from '../lib/derive.js';
|
|
8
|
+
import { projectApi, dataOf, slotOf, projectByKey, loadDecisions } from '../lib/store.js';
|
|
9
|
+
import { routes } from '../lib/routes.js';
|
|
10
|
+
import { projectHeader } from './project.js';
|
|
11
|
+
|
|
12
|
+
/** `<key>|<path>` -> { html, stale }. Marked stale, not dropped, when the project pushes. */
|
|
13
|
+
const renderCache = new Map();
|
|
14
|
+
/** Change files the user has open, kept across live re-renders. */
|
|
15
|
+
export const openFiles = new Set();
|
|
16
|
+
/** Decision search text, kept while moving between decisions. */
|
|
17
|
+
let decisionQuery = '';
|
|
18
|
+
|
|
19
|
+
export function clearRenderCache(key) {
|
|
20
|
+
for (const [cacheKey, entry] of renderCache) {
|
|
21
|
+
if (cacheKey.startsWith(key + '|')) entry.stale = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders a project file into `body` and calls `after` each time HTML lands.
|
|
27
|
+
* A stale copy is shown at once and swapped when the fresh one arrives: an
|
|
28
|
+
* agent editing a file pushes often, and a blank "rendering" flash on every
|
|
29
|
+
* push would throw the reader's place away.
|
|
30
|
+
*/
|
|
31
|
+
export async function renderMarkdown(key, project, path, body, after = () => {}) {
|
|
32
|
+
const cacheKey = key + '|' + path;
|
|
33
|
+
const cached = renderCache.get(cacheKey);
|
|
34
|
+
if (cached) {
|
|
35
|
+
body.innerHTML = cached.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
|
|
36
|
+
after();
|
|
37
|
+
if (!cached.stale) return;
|
|
38
|
+
} else {
|
|
39
|
+
body.replaceChildren(note('正在渲染.'));
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const data = await projectApi(key, '/render?file=' + encodeURIComponent(path));
|
|
43
|
+
renderCache.set(cacheKey, { html: data.html, stale: false });
|
|
44
|
+
if (cached && cached.html === data.html) return;
|
|
45
|
+
if (!body.isConnected && cached) return;
|
|
46
|
+
body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
|
|
47
|
+
after();
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (cached) return; // keep the last good render rather than replace it with an error
|
|
50
|
+
const offline = e.status === 503 || (project && !project.online);
|
|
51
|
+
body.replaceChildren(note(offline
|
|
52
|
+
? '文件內容由 session 即時渲染, 這個 project 目前沒有 session 連線. 在該 project 開啟 Claude Code 後就能閱讀.'
|
|
53
|
+
: '渲染失敗: ' + e.message));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Names the section the reader is in, recomputed on scroll: long specs and
|
|
59
|
+
* ADRs lose their heading off the top of the screen within a page or two.
|
|
60
|
+
*/
|
|
61
|
+
function runningHead(container, signal) {
|
|
62
|
+
const head = el('div', { class: 'running-head', 'aria-hidden': 'true' }, [el('span', { class: 'running-text' })]);
|
|
63
|
+
const update = () => {
|
|
64
|
+
const headings = container.querySelectorAll('h2, h3');
|
|
65
|
+
const offset = 120;
|
|
66
|
+
let current = null;
|
|
67
|
+
for (const h of headings) {
|
|
68
|
+
if (h.getBoundingClientRect().top <= offset) current = h;
|
|
69
|
+
else break;
|
|
70
|
+
}
|
|
71
|
+
head.firstChild.textContent = current ? current.textContent : '';
|
|
72
|
+
head.classList.toggle('is-empty', !current);
|
|
73
|
+
};
|
|
74
|
+
window.addEventListener('scroll', update, { passive: true, signal });
|
|
75
|
+
head.classList.add('is-empty');
|
|
76
|
+
return { head, update };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- spec ----------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export function renderSpec(route, ctx) {
|
|
82
|
+
const project = projectByKey(route.key);
|
|
83
|
+
const data = dataOf(route.key);
|
|
84
|
+
const spec = data.overview && data.overview.specs.find((s) => s.id === route.id);
|
|
85
|
+
const page = paintLine(el('div', { class: 'page page-doc' }), slotOf(route.key));
|
|
86
|
+
page.append(
|
|
87
|
+
el('header', { class: 'doc-head' }, [
|
|
88
|
+
el('h1', { class: 'page-title', text: route.id }),
|
|
89
|
+
spec ? el('p', { class: 'page-sub', text: `${spec.requirementCount} 條 requirement · tospec/specs/${route.id}/spec.md` }) : null,
|
|
90
|
+
])
|
|
91
|
+
);
|
|
92
|
+
const body = el('article', { class: 'rendered doc-body' });
|
|
93
|
+
const toc = el('nav', { class: 'doc-toc', 'aria-label': 'requirement 目錄' });
|
|
94
|
+
const { head, update } = runningHead(body, ctx.signal);
|
|
95
|
+
page.append(head, el('div', { class: 'doc-layout' }, [toc, body]));
|
|
96
|
+
if (!data.overview) ctx.waiting = true;
|
|
97
|
+
renderMarkdown(route.key, project, `tospec/specs/${route.id}/spec.md`, body, () => {
|
|
98
|
+
buildToc(toc, body);
|
|
99
|
+
update();
|
|
100
|
+
});
|
|
101
|
+
return page;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Requirement headings as buttons: hash links would fight the hash router. */
|
|
105
|
+
function buildToc(toc, body) {
|
|
106
|
+
const headings = [...body.querySelectorAll('h3')];
|
|
107
|
+
toc.replaceChildren();
|
|
108
|
+
toc.hidden = headings.length < 2;
|
|
109
|
+
if (toc.hidden) return;
|
|
110
|
+
toc.append(el('p', { class: 'toc-title', text: 'Requirement' }));
|
|
111
|
+
const list = el('ol', { class: 'toc-list' });
|
|
112
|
+
for (const h of headings) {
|
|
113
|
+
const button = el('button', { type: 'button', class: 'toc-link', text: h.textContent.replace(/^Requirement:\s*/i, '') });
|
|
114
|
+
button.addEventListener('click', () => h.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'start' }));
|
|
115
|
+
list.append(el('li', {}, [button]));
|
|
116
|
+
}
|
|
117
|
+
toc.append(list);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- decisions -------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export function renderDecisions(route, ctx) {
|
|
123
|
+
const project = projectByKey(route.key);
|
|
124
|
+
const page = paintLine(el('div', { class: 'page page-decisions' }), slotOf(route.key));
|
|
125
|
+
if (!project) {
|
|
126
|
+
ctx.waiting = true;
|
|
127
|
+
page.append(el('h1', { class: 'page-title', text: 'Decisions' }), note('找不到這個 project.'));
|
|
128
|
+
return page;
|
|
129
|
+
}
|
|
130
|
+
const data = dataOf(route.key);
|
|
131
|
+
page.append(projectHeader(project, 'decisions'));
|
|
132
|
+
if (!data.decisions) {
|
|
133
|
+
ctx.waiting = true;
|
|
134
|
+
if (!data.decisionsError) loadDecisions(route.key);
|
|
135
|
+
page.append(note(data.decisionsError ? '決策讀取失敗: ' + data.decisionsError : '正在讀取決策.'));
|
|
136
|
+
return page;
|
|
137
|
+
}
|
|
138
|
+
const decisions = data.decisions;
|
|
139
|
+
if (!decisions.length) {
|
|
140
|
+
page.append(note('還沒有決策記錄. 用 tospec-decision skill 或 tospec decision new <topic> 建立第一筆.'));
|
|
141
|
+
return page;
|
|
142
|
+
}
|
|
143
|
+
const active = decisions.find((d) => d.file === route.file) || decisions[0];
|
|
144
|
+
|
|
145
|
+
const search = el('input', { type: 'search', class: 'search', placeholder: '搜尋標題, status, 內文', 'aria-label': '搜尋決策' });
|
|
146
|
+
search.value = decisionQuery;
|
|
147
|
+
const list = el('ul', { class: 'decision-list' });
|
|
148
|
+
const fill = () => {
|
|
149
|
+
decisionQuery = search.value;
|
|
150
|
+
const q = decisionQuery.trim().toLowerCase();
|
|
151
|
+
const matches = decisions.filter((d) => !q || (d.title + ' ' + d.topic + ' ' + d.status + ' ' + d.content).toLowerCase().includes(q));
|
|
152
|
+
list.replaceChildren(...(matches.length
|
|
153
|
+
? matches.map((d) => el('li', {}, [
|
|
154
|
+
el('a', { class: 'decision-link', href: routes.decisions(route.key, d.file), 'aria-current': d === active ? 'page' : null }, [
|
|
155
|
+
el('span', { class: 'decision-title', text: d.title }),
|
|
156
|
+
el('span', { class: 'decision-meta' }, [el('span', { class: 'mono', text: formatStamp(d.date) }), el('span', { class: 'tag status-' + d.status, text: d.status })]),
|
|
157
|
+
]),
|
|
158
|
+
]))
|
|
159
|
+
: [el('li', {}, [note('沒有符合的決策.')])]));
|
|
160
|
+
};
|
|
161
|
+
search.addEventListener('input', fill);
|
|
162
|
+
fill();
|
|
163
|
+
const index = el('aside', { class: 'decision-index' }, [el('label', { class: 'search-wrap' }, [icon('search', 14), search]), list]);
|
|
164
|
+
|
|
165
|
+
const body = el('div', { class: 'rendered doc-body' });
|
|
166
|
+
const { head, update } = runningHead(body, ctx.signal);
|
|
167
|
+
const reader = el('article', { class: 'decision-reader' }, [
|
|
168
|
+
el('h2', { class: 'reader-title', text: active.title }),
|
|
169
|
+
el('dl', { class: 'meta' }, [el('dt', { text: 'date' }), el('dd', { text: formatStamp(active.date) }), el('dt', { text: 'status' }), el('dd', { text: active.status }), el('dt', { text: 'file' }), el('dd', { class: 'mono', text: active.file })]),
|
|
170
|
+
head,
|
|
171
|
+
body,
|
|
172
|
+
]);
|
|
173
|
+
renderMarkdown(route.key, project, active.path, body, () => {
|
|
174
|
+
// The reader already heads the page with the title the record's H1 repeats.
|
|
175
|
+
const h1 = body.querySelector('h1');
|
|
176
|
+
if (h1 && h1.textContent.trim() === active.title.trim()) h1.remove();
|
|
177
|
+
enhanceDecisionQA(body, active.content);
|
|
178
|
+
update();
|
|
179
|
+
});
|
|
180
|
+
page.append(el('div', { class: 'decision-layout' }, [index, reader]));
|
|
181
|
+
return page;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `{ q, a }` pairs from the `## 決策過程` section of raw markdown: a `**Q:**`
|
|
186
|
+
* line, then the answer until the next `**Q:**` or `## ` heading. Old-format or
|
|
187
|
+
* empty sections yield `[]`, which leaves the rendered markdown as it is.
|
|
188
|
+
*/
|
|
189
|
+
export function parseDecisionQA(markdown) {
|
|
190
|
+
if (!markdown) return [];
|
|
191
|
+
const start = markdown.search(/^##\s+決策過程\s*$/m);
|
|
192
|
+
if (start < 0) return [];
|
|
193
|
+
const rest = markdown.slice(start).replace(/^##\s+決策過程\s*$/m, '');
|
|
194
|
+
const next = rest.search(/^##\s/m);
|
|
195
|
+
const section = next >= 0 ? rest.slice(0, next) : rest;
|
|
196
|
+
const pairs = [];
|
|
197
|
+
for (const block of section.split(/^\*\*Q:\*\*/m).slice(1)) {
|
|
198
|
+
const nl = block.indexOf('\n');
|
|
199
|
+
const q = (nl < 0 ? block : block.slice(0, nl)).trim();
|
|
200
|
+
const a = (nl < 0 ? '' : block.slice(nl + 1)).trim();
|
|
201
|
+
if (q) pairs.push({ q, a });
|
|
202
|
+
}
|
|
203
|
+
return pairs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Swaps the rendered 決策過程 body (always the last section) for Q/A pairs, keeping its heading. */
|
|
207
|
+
function enhanceDecisionQA(body, markdown) {
|
|
208
|
+
const pairs = parseDecisionQA(markdown);
|
|
209
|
+
if (!pairs.length) return;
|
|
210
|
+
const heading = [...body.querySelectorAll('h2')].find((h) => h.textContent.trim() === '決策過程');
|
|
211
|
+
if (!heading) return;
|
|
212
|
+
while (heading.nextSibling) heading.nextSibling.remove();
|
|
213
|
+
heading.after(el('dl', { class: 'qa-list' }, pairs.flatMap((p) => [el('dt', { class: 'qa-q', text: p.q }), el('dd', { class: 'qa-a', text: p.a })])));
|
|
214
|
+
}
|