@isaacriehm/cairn-core 0.4.1 → 0.4.3
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/dist/.tsbuildinfo +1 -1
- package/dist/attention/bulk-accept.js +1 -1
- package/dist/attention/bulk-accept.js.map +1 -1
- package/dist/attention/dedup.d.ts +2 -2
- package/dist/attention/dedup.js +15 -4
- package/dist/attention/dedup.js.map +1 -1
- package/dist/attention/index.d.ts +1 -0
- package/dist/attention/index.js +1 -0
- package/dist/attention/index.js.map +1 -1
- package/dist/attention/restore.js +1 -1
- package/dist/attention/restore.js.map +1 -1
- package/dist/attention/serve/api.d.ts +23 -0
- package/dist/attention/serve/api.js +344 -0
- package/dist/attention/serve/api.js.map +1 -0
- package/dist/attention/serve/index.d.ts +62 -0
- package/dist/attention/serve/index.js +205 -0
- package/dist/attention/serve/index.js.map +1 -0
- package/dist/decision-capture/id.d.ts +62 -25
- package/dist/decision-capture/id.js +78 -57
- package/dist/decision-capture/id.js.map +1 -1
- package/dist/decision-capture/index.d.ts +3 -3
- package/dist/decision-capture/index.js +3 -3
- package/dist/decision-capture/index.js.map +1 -1
- package/dist/ground/schemas.js +2 -2
- package/dist/ground/schemas.js.map +1 -1
- package/dist/ground/scope-index.js +2 -2
- package/dist/ground/scope-index.js.map +1 -1
- package/dist/hooks/post-tool-use/citation-scanner.d.ts +1 -1
- package/dist/hooks/post-tool-use/citation-scanner.js +3 -3
- package/dist/hooks/post-tool-use/citation-scanner.js.map +1 -1
- package/dist/hooks/post-tool-use/copy-scanner.js +1 -1
- package/dist/hooks/post-tool-use/copy-scanner.js.map +1 -1
- package/dist/hooks/post-tool-use/legend-builder.d.ts +1 -1
- package/dist/hooks/post-tool-use/legend-builder.js +2 -2
- package/dist/hooks/post-tool-use/legend-builder.js.map +1 -1
- package/dist/init/ingest-docs.js +10 -6
- package/dist/init/ingest-docs.js.map +1 -1
- package/dist/init/mapper-parallel.js +1 -1
- package/dist/init/mapper-parallel.js.map +1 -1
- package/dist/init/rules-merge/ingest.js +9 -2
- package/dist/init/rules-merge/ingest.js.map +1 -1
- package/dist/init/source-comments/ingest.js +16 -4
- package/dist/init/source-comments/ingest.js.map +1 -1
- package/dist/mcp/bootstrap-guard.d.ts +19 -8
- package/dist/mcp/bootstrap-guard.js +41 -11
- package/dist/mcp/bootstrap-guard.js.map +1 -1
- package/dist/mcp/history/summarizer.js +1 -1
- package/dist/mcp/history/summarizer.js.map +1 -1
- package/dist/mcp/schemas.d.ts +1 -1
- package/dist/mcp/schemas.js +5 -5
- package/dist/mcp/schemas.js.map +1 -1
- package/dist/mcp/tools/archive.js +1 -1
- package/dist/mcp/tools/archive.js.map +1 -1
- package/dist/mcp/tools/attention-restore.js +1 -1
- package/dist/mcp/tools/attention-restore.js.map +1 -1
- package/dist/mcp/tools/attention-serve.d.ts +23 -0
- package/dist/mcp/tools/attention-serve.js +78 -0
- package/dist/mcp/tools/attention-serve.js.map +1 -0
- package/dist/mcp/tools/attention-wait.d.ts +18 -0
- package/dist/mcp/tools/attention-wait.js +74 -0
- package/dist/mcp/tools/attention-wait.js.map +1 -0
- package/dist/mcp/tools/index.js +4 -0
- package/dist/mcp/tools/index.js.map +1 -1
- package/dist/mcp/tools/record-decision.js +14 -2
- package/dist/mcp/tools/record-decision.js.map +1 -1
- package/dist/mcp/tools/resolve-attention.js +2 -2
- package/dist/mcp/tools/resolve-attention.js.map +1 -1
- package/package.json +1 -1
- package/templates/attention-ui/app.css +406 -0
- package/templates/attention-ui/app.js +384 -0
- package/templates/attention-ui/index.html +56 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cairn attention triage UI — vanilla JS module.
|
|
3
|
+
*
|
|
4
|
+
* No framework, no build step. Fetches `/api/state` on load, renders
|
|
5
|
+
* draft cards + cluster list, dispatches accept/reject/edit/merge to
|
|
6
|
+
* the JSON API. Polls heartbeat every 5s while the page is open so the
|
|
7
|
+
* server's idle timer doesn't shut us down mid-triage.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const HEARTBEAT_MS = 5_000;
|
|
11
|
+
|
|
12
|
+
const state = {
|
|
13
|
+
drafts: [],
|
|
14
|
+
clusters: [],
|
|
15
|
+
counts: {},
|
|
16
|
+
selectedClusterIdx: null,
|
|
17
|
+
focusedDraftIdx: 0,
|
|
18
|
+
editing: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/* ── api helpers ───────────────────────────────────────────────── */
|
|
22
|
+
|
|
23
|
+
async function api(path, opts = {}) {
|
|
24
|
+
const res = await fetch(path, {
|
|
25
|
+
method: opts.method ?? "GET",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
28
|
+
});
|
|
29
|
+
return res.json();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function refresh() {
|
|
33
|
+
const data = await api("/api/state");
|
|
34
|
+
state.drafts = data.drafts ?? [];
|
|
35
|
+
state.clusters = data.clusters ?? [];
|
|
36
|
+
state.counts = data.counts ?? {};
|
|
37
|
+
if (state.focusedDraftIdx >= state.drafts.length) {
|
|
38
|
+
state.focusedDraftIdx = Math.max(0, state.drafts.length - 1);
|
|
39
|
+
}
|
|
40
|
+
render();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ── rendering ─────────────────────────────────────────────────── */
|
|
44
|
+
|
|
45
|
+
function render() {
|
|
46
|
+
renderCounters();
|
|
47
|
+
renderClusters();
|
|
48
|
+
renderDrafts();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderCounters() {
|
|
52
|
+
const c = state.counts;
|
|
53
|
+
document.getElementById("counters").innerHTML = `
|
|
54
|
+
<span class="pill"><span class="num">${state.drafts.length}</span> remaining</span>
|
|
55
|
+
<span class="pill"><span class="num">${c.accepted ?? 0}</span> accepted</span>
|
|
56
|
+
<span class="pill"><span class="num">${c.rejected ?? 0}</span> rejected</span>
|
|
57
|
+
<span class="pill"><span class="num">${c.merged ?? 0}</span> merged</span>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderClusters() {
|
|
62
|
+
const list = document.getElementById("cluster-list");
|
|
63
|
+
if (state.clusters.length === 0) {
|
|
64
|
+
list.innerHTML = `<div class="empty" style="padding:8px 0">no duplicate clusters</div>`;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
list.innerHTML = state.clusters
|
|
68
|
+
.map(
|
|
69
|
+
(c, i) => `
|
|
70
|
+
<div class="cluster-item ${state.selectedClusterIdx === i ? "active" : ""}" data-idx="${i}">
|
|
71
|
+
<div class="row">
|
|
72
|
+
<strong>${c.drafts.length} drafts</strong>
|
|
73
|
+
<span class="badge ${c.tier}">${c.tier}</span>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="meta">avg sim ${c.averageSimilarity.toFixed(2)} · merge keeps ${c.drafts[0].id}</div>
|
|
76
|
+
</div>
|
|
77
|
+
`,
|
|
78
|
+
)
|
|
79
|
+
.join("");
|
|
80
|
+
list.querySelectorAll(".cluster-item").forEach((el) =>
|
|
81
|
+
el.addEventListener("click", () => {
|
|
82
|
+
state.selectedClusterIdx =
|
|
83
|
+
state.selectedClusterIdx === Number(el.dataset.idx)
|
|
84
|
+
? null
|
|
85
|
+
: Number(el.dataset.idx);
|
|
86
|
+
render();
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderDrafts() {
|
|
92
|
+
const pane = document.getElementById("draft-list");
|
|
93
|
+
const empty = document.getElementById("empty-state");
|
|
94
|
+
const title = document.getElementById("pane-title");
|
|
95
|
+
|
|
96
|
+
if (state.selectedClusterIdx !== null) {
|
|
97
|
+
const cluster = state.clusters[state.selectedClusterIdx];
|
|
98
|
+
title.textContent = `cluster · ${cluster.drafts.length} drafts · ${cluster.tier}`;
|
|
99
|
+
if (cluster.drafts.length === 0) {
|
|
100
|
+
pane.innerHTML = "";
|
|
101
|
+
empty.classList.remove("hidden");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
empty.classList.add("hidden");
|
|
105
|
+
const survivor = cluster.drafts[0];
|
|
106
|
+
pane.innerHTML =
|
|
107
|
+
`<div class="draft-card cluster-card">
|
|
108
|
+
<div class="meta">
|
|
109
|
+
<span class="chip">survivor: ${survivor.id}</span>
|
|
110
|
+
<span class="chip">${cluster.drafts.length - 1} duplicates rejected on merge</span>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="actions-row">
|
|
113
|
+
<button class="primary" id="cluster-merge">merge cluster (keep ${survivor.id})</button>
|
|
114
|
+
<button class="ghost" id="cluster-cancel">cancel</button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>` +
|
|
117
|
+
cluster.drafts
|
|
118
|
+
.map((d, idx) => renderDraftCardForCluster(d, idx === 0))
|
|
119
|
+
.join("");
|
|
120
|
+
document
|
|
121
|
+
.getElementById("cluster-merge")
|
|
122
|
+
.addEventListener("click", () => mergeCluster());
|
|
123
|
+
document
|
|
124
|
+
.getElementById("cluster-cancel")
|
|
125
|
+
.addEventListener("click", () => {
|
|
126
|
+
state.selectedClusterIdx = null;
|
|
127
|
+
render();
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
title.textContent = `drafts · ${state.drafts.length}`;
|
|
133
|
+
if (state.drafts.length === 0) {
|
|
134
|
+
pane.innerHTML = "";
|
|
135
|
+
empty.classList.remove("hidden");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
empty.classList.add("hidden");
|
|
139
|
+
pane.innerHTML = state.drafts
|
|
140
|
+
.map((d, i) => renderDraftCard(d, i === state.focusedDraftIdx))
|
|
141
|
+
.join("");
|
|
142
|
+
attachDraftHandlers();
|
|
143
|
+
scrollFocusedIntoView();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderDraftCard(d, focused) {
|
|
147
|
+
const editingThis = state.editing?.id === d.id;
|
|
148
|
+
return `
|
|
149
|
+
<article class="draft-card ${focused ? "focus" : ""}" data-id="${d.id}">
|
|
150
|
+
<div class="title">
|
|
151
|
+
<span class="id">${d.id}</span>
|
|
152
|
+
<span>${escapeHtml(d.title)}</span>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="meta">
|
|
155
|
+
${d.source ? `<span class="chip">${d.source}</span>` : ""}
|
|
156
|
+
${d.sourceFile ? `<span class="chip">${escapeHtml(d.sourceFile)}</span>` : ""}
|
|
157
|
+
${d.confidence ? `<span class="chip">${d.confidence}</span>` : ""}
|
|
158
|
+
</div>
|
|
159
|
+
${editingThis ? renderEditor(d) : renderBody(d)}
|
|
160
|
+
${editingThis ? "" : `
|
|
161
|
+
<div class="actions-row">
|
|
162
|
+
<button class="primary" data-action="accept">accept</button>
|
|
163
|
+
<button class="danger" data-action="reject">reject</button>
|
|
164
|
+
<button class="ghost" data-action="edit">edit</button>
|
|
165
|
+
</div>`}
|
|
166
|
+
</article>
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderDraftCardForCluster(d, isSurvivor) {
|
|
171
|
+
return `
|
|
172
|
+
<article class="draft-card cluster-card">
|
|
173
|
+
<div class="title">
|
|
174
|
+
<span class="id">${d.id}</span>
|
|
175
|
+
<span>${escapeHtml(d.title)}${isSurvivor ? " · survivor" : ""}</span>
|
|
176
|
+
</div>
|
|
177
|
+
<div class="meta">
|
|
178
|
+
${d.sourceFile ? `<span class="chip">${escapeHtml(d.sourceFile)}</span>` : ""}
|
|
179
|
+
</div>
|
|
180
|
+
</article>
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderBody(d) {
|
|
185
|
+
const text = d.proposedRationale ?? d.body ?? "";
|
|
186
|
+
return `<div class="body">${escapeHtml(text)}</div>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderEditor(d) {
|
|
190
|
+
const titleVal = state.editing?.title ?? d.title;
|
|
191
|
+
const bodyVal = state.editing?.body_markdown ?? d.proposedRationale ?? d.body ?? "";
|
|
192
|
+
return `
|
|
193
|
+
<div class="editor">
|
|
194
|
+
<input type="text" id="edit-title" value="${escapeAttr(titleVal)}" placeholder="title" />
|
|
195
|
+
<textarea id="edit-body" placeholder="rationale / body markdown">${escapeHtml(bodyVal)}</textarea>
|
|
196
|
+
<div class="actions-row">
|
|
197
|
+
<button class="primary" data-action="save-edit">save</button>
|
|
198
|
+
<button class="ghost" data-action="cancel-edit">cancel</button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function attachDraftHandlers() {
|
|
205
|
+
document.querySelectorAll(".draft-card[data-id]").forEach((el) => {
|
|
206
|
+
const id = el.dataset.id;
|
|
207
|
+
el.querySelectorAll("button[data-action]").forEach((btn) => {
|
|
208
|
+
btn.addEventListener("click", async (ev) => {
|
|
209
|
+
ev.stopPropagation();
|
|
210
|
+
const action = btn.dataset.action;
|
|
211
|
+
if (action === "accept") return acceptDraft(id);
|
|
212
|
+
if (action === "reject") return rejectDraft(id);
|
|
213
|
+
if (action === "edit") {
|
|
214
|
+
state.editing = { id };
|
|
215
|
+
render();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (action === "save-edit") return saveEdit(id);
|
|
219
|
+
if (action === "cancel-edit") {
|
|
220
|
+
state.editing = null;
|
|
221
|
+
render();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
el.addEventListener("click", () => {
|
|
227
|
+
const idx = state.drafts.findIndex((d) => d.id === id);
|
|
228
|
+
if (idx >= 0) {
|
|
229
|
+
state.focusedDraftIdx = idx;
|
|
230
|
+
render();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function scrollFocusedIntoView() {
|
|
237
|
+
const focused = document.querySelector(".draft-card.focus");
|
|
238
|
+
if (focused) {
|
|
239
|
+
focused.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ── actions ───────────────────────────────────────────────────── */
|
|
244
|
+
|
|
245
|
+
async function acceptDraft(id) {
|
|
246
|
+
setStatus(`accepting ${id}…`);
|
|
247
|
+
const r = await api(`/api/draft/${id}/accept`, { method: "POST" });
|
|
248
|
+
if (!r.ok) {
|
|
249
|
+
setStatus(`accept failed: ${r.error ?? "unknown"}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
setStatus(`accepted ${id}`);
|
|
253
|
+
await refresh();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function rejectDraft(id) {
|
|
257
|
+
setStatus(`rejecting ${id}…`);
|
|
258
|
+
const r = await api(`/api/draft/${id}/reject`, { method: "POST" });
|
|
259
|
+
if (!r.ok) {
|
|
260
|
+
setStatus(`reject failed`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
setStatus(`rejected ${id}`);
|
|
264
|
+
await refresh();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function saveEdit(id) {
|
|
268
|
+
const title = document.getElementById("edit-title").value;
|
|
269
|
+
const body = document.getElementById("edit-body").value;
|
|
270
|
+
setStatus(`saving ${id}…`);
|
|
271
|
+
const r = await api(`/api/draft/${id}/edit`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
body: { title, body_markdown: body },
|
|
274
|
+
});
|
|
275
|
+
if (!r.ok) {
|
|
276
|
+
setStatus(`edit failed: ${r.error ?? "unknown"}`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
state.editing = null;
|
|
280
|
+
setStatus(`saved ${id}`);
|
|
281
|
+
await refresh();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function mergeCluster() {
|
|
285
|
+
if (state.selectedClusterIdx === null) return;
|
|
286
|
+
const cluster = state.clusters[state.selectedClusterIdx];
|
|
287
|
+
const survivor = cluster.drafts[0].id;
|
|
288
|
+
const members = cluster.drafts.map((d) => d.id);
|
|
289
|
+
setStatus(`merging cluster, keeping ${survivor}…`);
|
|
290
|
+
const r = await api("/api/cluster/merge", {
|
|
291
|
+
method: "POST",
|
|
292
|
+
body: { survivor_id: survivor, member_ids: members },
|
|
293
|
+
});
|
|
294
|
+
if (!r.ok) {
|
|
295
|
+
setStatus(`merge failed`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
setStatus(`merged — kept ${survivor}, rejected ${r.rejected}`);
|
|
299
|
+
state.selectedClusterIdx = null;
|
|
300
|
+
await refresh();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function bulkAcceptHigh() {
|
|
304
|
+
setStatus("bulk-accepting high-confidence drafts…");
|
|
305
|
+
const r = await api("/api/bulk-accept", {
|
|
306
|
+
method: "POST",
|
|
307
|
+
body: { threshold: "high" },
|
|
308
|
+
});
|
|
309
|
+
if (r.ok) {
|
|
310
|
+
setStatus(`bulk-accepted ${r.decsAccepted} drafts`);
|
|
311
|
+
} else {
|
|
312
|
+
setStatus(`bulk-accept failed`);
|
|
313
|
+
}
|
|
314
|
+
await refresh();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function done() {
|
|
318
|
+
setStatus("finalizing…");
|
|
319
|
+
await api("/api/done", { method: "POST" });
|
|
320
|
+
setStatus("done. Claude Code session resuming.");
|
|
321
|
+
document.body.style.opacity = "0.5";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* ── keyboard ──────────────────────────────────────────────────── */
|
|
325
|
+
|
|
326
|
+
document.addEventListener("keydown", (ev) => {
|
|
327
|
+
if (ev.target.tagName === "INPUT" || ev.target.tagName === "TEXTAREA") return;
|
|
328
|
+
const key = ev.key;
|
|
329
|
+
if (key === "j") {
|
|
330
|
+
state.focusedDraftIdx = Math.min(
|
|
331
|
+
state.drafts.length - 1,
|
|
332
|
+
state.focusedDraftIdx + 1,
|
|
333
|
+
);
|
|
334
|
+
render();
|
|
335
|
+
} else if (key === "k") {
|
|
336
|
+
state.focusedDraftIdx = Math.max(0, state.focusedDraftIdx - 1);
|
|
337
|
+
render();
|
|
338
|
+
} else if (key === "a") {
|
|
339
|
+
const d = state.drafts[state.focusedDraftIdx];
|
|
340
|
+
if (d) acceptDraft(d.id);
|
|
341
|
+
} else if (key === "r") {
|
|
342
|
+
const d = state.drafts[state.focusedDraftIdx];
|
|
343
|
+
if (d) rejectDraft(d.id);
|
|
344
|
+
} else if (key === "e") {
|
|
345
|
+
const d = state.drafts[state.focusedDraftIdx];
|
|
346
|
+
if (d) {
|
|
347
|
+
state.editing = { id: d.id };
|
|
348
|
+
render();
|
|
349
|
+
}
|
|
350
|
+
} else if (key === "m") {
|
|
351
|
+
if (state.selectedClusterIdx !== null) mergeCluster();
|
|
352
|
+
} else if (key === "?") {
|
|
353
|
+
document.querySelector(".right").classList.toggle("hidden");
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
/* ── helpers ───────────────────────────────────────────────────── */
|
|
358
|
+
|
|
359
|
+
function setStatus(msg) {
|
|
360
|
+
document.getElementById("status-line").textContent = msg;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function escapeHtml(s) {
|
|
364
|
+
if (s === null || s === undefined) return "";
|
|
365
|
+
return String(s)
|
|
366
|
+
.replace(/&/g, "&")
|
|
367
|
+
.replace(/</g, "<")
|
|
368
|
+
.replace(/>/g, ">");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function escapeAttr(s) {
|
|
372
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* ── boot ──────────────────────────────────────────────────────── */
|
|
376
|
+
|
|
377
|
+
document.getElementById("done").addEventListener("click", done);
|
|
378
|
+
document.getElementById("bulk-high").addEventListener("click", bulkAcceptHigh);
|
|
379
|
+
|
|
380
|
+
setInterval(() => {
|
|
381
|
+
fetch("/api/heartbeat", { method: "POST" }).catch(() => {});
|
|
382
|
+
}, HEARTBEAT_MS);
|
|
383
|
+
|
|
384
|
+
refresh();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Cairn ⬡ Attention</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/app.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="topbar">
|
|
11
|
+
<div class="brand">
|
|
12
|
+
<span class="hex">⬡</span>
|
|
13
|
+
<span class="name">cairn attention</span>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="counters" id="counters"></div>
|
|
16
|
+
<div class="actions">
|
|
17
|
+
<button id="bulk-high" class="ghost" title="Auto-accept high-confidence drafts">
|
|
18
|
+
accept high-confidence
|
|
19
|
+
</button>
|
|
20
|
+
<button id="done" class="primary" title="Finish triage and resume Claude Code">
|
|
21
|
+
I'm done
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<main class="grid">
|
|
27
|
+
<aside class="left" id="clusters">
|
|
28
|
+
<h2>clusters</h2>
|
|
29
|
+
<div id="cluster-list" class="cluster-list"></div>
|
|
30
|
+
</aside>
|
|
31
|
+
|
|
32
|
+
<section class="center" id="drafts-pane">
|
|
33
|
+
<h2 id="pane-title">drafts</h2>
|
|
34
|
+
<div id="draft-list" class="draft-list"></div>
|
|
35
|
+
<div id="empty-state" class="empty hidden">
|
|
36
|
+
Inbox is clear. Nothing left to triage.
|
|
37
|
+
</div>
|
|
38
|
+
</section>
|
|
39
|
+
|
|
40
|
+
<aside class="right">
|
|
41
|
+
<h2>shortcuts</h2>
|
|
42
|
+
<ul class="keys">
|
|
43
|
+
<li><kbd>j</kbd> / <kbd>k</kbd> — next / prev</li>
|
|
44
|
+
<li><kbd>a</kbd> — accept</li>
|
|
45
|
+
<li><kbd>r</kbd> — reject</li>
|
|
46
|
+
<li><kbd>e</kbd> — edit</li>
|
|
47
|
+
<li><kbd>m</kbd> — merge cluster</li>
|
|
48
|
+
<li><kbd>?</kbd> — toggle this</li>
|
|
49
|
+
</ul>
|
|
50
|
+
<div class="status" id="status-line">ready</div>
|
|
51
|
+
</aside>
|
|
52
|
+
</main>
|
|
53
|
+
|
|
54
|
+
<script type="module" src="/static/app.js"></script>
|
|
55
|
+
</body>
|
|
56
|
+
</html>
|