@howaboua/pi-agent-board 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,485 @@
1
+ import { marked } from "/vendor/marked.js";
2
+ import DOMPurify from "/vendor/purify.js";
3
+
4
+ const $ = (id) => document.getElementById(id);
5
+ const fragment = new URLSearchParams(location.hash.slice(1));
6
+ const token = fragment.get("view");
7
+ const state = {
8
+ board: "",
9
+ channel: "",
10
+ author: "",
11
+ query: "",
12
+ thread: "",
13
+ detail: null,
14
+ page: null,
15
+ conversation: null,
16
+ firstPage: null,
17
+ firstConversation: null,
18
+ threadPages: 1,
19
+ replyPages: 1,
20
+ };
21
+ let queue = Promise.resolve();
22
+ let busy = 0;
23
+ let changed = false;
24
+
25
+ function run(action) {
26
+ busy++;
27
+ queue = queue.then(async () => {
28
+ try {
29
+ await action();
30
+ $("status").textContent = "";
31
+ } catch (error) {
32
+ $("status").textContent = error.message;
33
+ } finally {
34
+ busy--;
35
+ }
36
+ });
37
+ return queue;
38
+ }
39
+
40
+ async function api(path, params = {}) {
41
+ const url = new URL(path, location.origin);
42
+ for (const [key, value] of Object.entries(params))
43
+ if (value) url.searchParams.set(key, value);
44
+ const response = await fetch(url, {
45
+ headers: { Authorization: `Bearer ${token}` },
46
+ cache: "no-store",
47
+ signal: AbortSignal.timeout(10_000),
48
+ });
49
+ const data = await response.json();
50
+ if (!response.ok) {
51
+ if (response.status === 401) $("live").checked = false;
52
+ throw new Error(
53
+ typeof data.error === "string"
54
+ ? data.error
55
+ : `Request failed (${response.status}).`,
56
+ );
57
+ }
58
+ return data;
59
+ }
60
+
61
+ function node(tag, className, text) {
62
+ const element = document.createElement(tag);
63
+ if (className) element.className = className;
64
+ if (text !== undefined) element.textContent = text;
65
+ return element;
66
+ }
67
+
68
+ function shortName(name) {
69
+ return (name.split("/").filter(Boolean).at(-1) || name).replace(
70
+ /-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i,
71
+ "",
72
+ );
73
+ }
74
+ function author(name) {
75
+ const element = node("span", "author", shortName(name));
76
+ element.title = name;
77
+ return element;
78
+ }
79
+ function date(value) {
80
+ const parsed = new Date(value);
81
+ return Number.isNaN(parsed.getTime())
82
+ ? value
83
+ : parsed.toLocaleString(undefined, {
84
+ month: "short",
85
+ day: "numeric",
86
+ hour: "2-digit",
87
+ minute: "2-digit",
88
+ });
89
+ }
90
+ function empty(target, text) {
91
+ target.replaceChildren(node("p", "empty", text));
92
+ }
93
+ function selectionURL(thread = state.thread) {
94
+ const params = new URLSearchParams();
95
+ params.set("view", token);
96
+ if (state.board) params.set("board", state.board);
97
+ if (thread) params.set("thread", thread);
98
+ return `#${params}`;
99
+ }
100
+ function saveSelection() {
101
+ history.replaceState(null, "", selectionURL());
102
+ }
103
+ function threadParams() {
104
+ return {
105
+ boardId: state.board,
106
+ channel: state.channel,
107
+ author: state.author,
108
+ q: state.query,
109
+ };
110
+ }
111
+ function same(a, b) {
112
+ return JSON.stringify(a) === JSON.stringify(b);
113
+ }
114
+
115
+ function renderBoard() {
116
+ const detail = state.detail;
117
+ const channels = [
118
+ { name: "", label: "All activity" },
119
+ ...detail.channels.map((channel) => ({
120
+ name: channel.name,
121
+ label: channel.name,
122
+ posts: channel.posts,
123
+ })),
124
+ ];
125
+ $("channels").replaceChildren(
126
+ ...channels.map((channel) => {
127
+ const button = node("button", "", channel.label);
128
+ button.type = "button";
129
+ button.dataset.channel = channel.name;
130
+ button.setAttribute(
131
+ "aria-current",
132
+ String(state.channel === channel.name),
133
+ );
134
+ if (channel.posts !== undefined)
135
+ button.append(node("small", "", String(channel.posts)));
136
+ button.onclick = () =>
137
+ run(async () => {
138
+ state.channel = channel.name;
139
+ await loadList();
140
+ renderBoard();
141
+ });
142
+ return button;
143
+ }),
144
+ );
145
+ // Polling never replaces focused controls when their options have not changed.
146
+ const options = [
147
+ { name: "", label: "All agents" },
148
+ ...detail.agents.map((agent) => ({
149
+ name: agent.name,
150
+ label: shortName(agent.name),
151
+ })),
152
+ ];
153
+ const signature = JSON.stringify(options);
154
+ if ($("author").dataset.options !== signature) {
155
+ $("author").replaceChildren(
156
+ ...options.map((option) => {
157
+ const element = node("option", "", option.label);
158
+ element.value = option.name;
159
+ element.title = option.name;
160
+ return element;
161
+ }),
162
+ );
163
+ $("author").dataset.options = signature;
164
+ }
165
+ $("author").value = state.author;
166
+ const watching = detail.subscriptions.filter(
167
+ (subscription) => subscription.enabled,
168
+ );
169
+ $("subscriptions").replaceChildren(
170
+ ...watching.map((subscription) => {
171
+ const line = node("p");
172
+ line.append(
173
+ author(subscription.agent),
174
+ document.createTextNode(` watches ${subscription.target}`),
175
+ );
176
+ return line;
177
+ }),
178
+ );
179
+ $("watching").hidden = !watching.length;
180
+ }
181
+
182
+ function renderThreads() {
183
+ $("list-title").textContent = state.channel || "All activity";
184
+ $("threads").replaceChildren(
185
+ ...state.page.threads.map((thread) => {
186
+ const link = node("a", "thread");
187
+ link.href = selectionURL(thread.id);
188
+ link.dataset.thread = thread.id;
189
+ link.setAttribute("aria-current", String(thread.id === state.thread));
190
+ const meta = node("div", "thread-meta");
191
+ meta.append(
192
+ author(thread.author),
193
+ node("span", "", date(thread.lastActivity)),
194
+ );
195
+ const foot = node("div", "thread-foot");
196
+ foot.append(
197
+ node("span", "", thread.channel),
198
+ node(
199
+ "span",
200
+ "",
201
+ `${thread.replies} ${thread.replies === 1 ? "reply" : "replies"}`,
202
+ ),
203
+ );
204
+ link.append(meta, node("p", "thread-preview", thread.preview), foot);
205
+ link.onclick = (event) => {
206
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
207
+ return;
208
+ event.preventDefault();
209
+ run(() => openThread(thread.id));
210
+ };
211
+ return link;
212
+ }),
213
+ );
214
+ if (!state.page.threads.length)
215
+ empty(
216
+ $("threads"),
217
+ state.query || state.author ? "No matches" : "No posts",
218
+ );
219
+ $("more-threads").hidden = !state.page.nextCursor;
220
+ }
221
+
222
+ function renderPost(post, root) {
223
+ const article = node("article", "post");
224
+ if (root) article.append(node("div", "post-channel", post.channel));
225
+ const header = node("header", "post-header");
226
+ const time = node("time", "", date(post.createdAt));
227
+ time.dateTime = post.createdAt;
228
+ time.title = post.createdAt;
229
+ header.append(author(post.author), time);
230
+ const body = node("div", "prose");
231
+ body.innerHTML = DOMPurify.sanitize(
232
+ marked.parse(post.text, { async: false }),
233
+ {
234
+ ALLOWED_TAGS: [
235
+ "p",
236
+ "br",
237
+ "hr",
238
+ "strong",
239
+ "em",
240
+ "del",
241
+ "blockquote",
242
+ "pre",
243
+ "code",
244
+ "ul",
245
+ "ol",
246
+ "li",
247
+ "h1",
248
+ "h2",
249
+ "h3",
250
+ "h4",
251
+ "h5",
252
+ "h6",
253
+ "a",
254
+ "table",
255
+ "thead",
256
+ "tbody",
257
+ "tr",
258
+ "th",
259
+ "td",
260
+ ],
261
+ ALLOWED_ATTR: ["href", "title", "start", "colspan", "rowspan"],
262
+ ALLOW_DATA_ATTR: false,
263
+ },
264
+ );
265
+ for (const link of body.querySelectorAll("a")) {
266
+ const href = link.getAttribute("href");
267
+ // Relative links could carry the capability fragment to another local page.
268
+ if (!href || !/^(https?:|mailto:)/i.test(href))
269
+ link.removeAttribute("href");
270
+ else {
271
+ link.rel = "noopener noreferrer";
272
+ link.target = "_blank";
273
+ }
274
+ }
275
+ article.append(header, body);
276
+ return article;
277
+ }
278
+ function renderConversation() {
279
+ const conversation = state.conversation;
280
+ $("posts").replaceChildren(
281
+ renderPost(conversation.root, true),
282
+ ...conversation.replies.map((post) => renderPost(post, false)),
283
+ );
284
+ $("more-replies").hidden = !conversation.nextCursor;
285
+ }
286
+ function clearConversation() {
287
+ state.thread = "";
288
+ state.conversation = null;
289
+ state.firstConversation = null;
290
+ state.replyPages = 1;
291
+ $("more-replies").hidden = true;
292
+ document.body.classList.remove("detail-open");
293
+ $("posts").replaceChildren();
294
+ saveSelection();
295
+ }
296
+ async function loadList() {
297
+ const page = await api("/api/threads", threadParams());
298
+ state.page = page;
299
+ state.firstPage = page;
300
+ state.threadPages = 1;
301
+ clearConversation();
302
+ renderThreads();
303
+ $("threads").closest(".index").scrollTop = 0;
304
+ setChanged(false);
305
+ if (page.threads[0]) await openThread(page.threads[0].id);
306
+ }
307
+ async function openThread(id) {
308
+ const conversation = await api("/api/thread", { boardId: state.board, id });
309
+ state.thread = id;
310
+ state.conversation = conversation;
311
+ state.firstConversation = conversation;
312
+ state.replyPages = 1;
313
+ renderConversation();
314
+ for (const link of $("threads").querySelectorAll("a"))
315
+ link.setAttribute("aria-current", String(link.dataset.thread === id));
316
+ saveSelection();
317
+ document.body.classList.add("detail-open");
318
+ $("conversation").scrollTop = 0;
319
+ if (matchMedia("(max-width: 760px)").matches) {
320
+ window.scrollTo(0, 0);
321
+ $("back").focus();
322
+ }
323
+ }
324
+ async function loadBoard(id) {
325
+ const detail = await api("/api/board", { boardId: id });
326
+ state.board = id;
327
+ state.detail = detail;
328
+ state.channel = "";
329
+ state.author = "";
330
+ renderBoard();
331
+ await loadList();
332
+ }
333
+ function setChanged(value) {
334
+ changed = value;
335
+ $("refresh").textContent = value ? "New activity · Refresh" : "Refresh";
336
+ }
337
+
338
+ async function refresh(explicit = false) {
339
+ const focused = document.activeElement;
340
+ const focusKey =
341
+ focused?.dataset?.channel !== undefined
342
+ ? ["channel", focused.dataset.channel]
343
+ : focused?.dataset?.thread
344
+ ? ["thread", focused.dataset.thread]
345
+ : null;
346
+ const params = threadParams();
347
+ const [detail, page, conversation] = await Promise.all([
348
+ api("/api/board", { boardId: state.board }),
349
+ api("/api/threads", params),
350
+ state.thread
351
+ ? api("/api/thread", { boardId: state.board, id: state.thread })
352
+ : Promise.resolve(null),
353
+ ]);
354
+ if (!explicit && (state.threadPages > 1 || state.replyPages > 1)) {
355
+ // Compare only loaded first pages. Never splice fresh cursors into an old tail.
356
+ if (
357
+ !same(detail, state.detail) ||
358
+ !same(page, state.firstPage) ||
359
+ !same(conversation, state.firstConversation)
360
+ )
361
+ setChanged(true);
362
+ return;
363
+ }
364
+ if (!same(detail, state.detail)) {
365
+ state.detail = detail;
366
+ renderBoard();
367
+ }
368
+ if (!same(page, state.page)) {
369
+ state.page = page;
370
+ renderThreads();
371
+ }
372
+ if (conversation && !same(conversation, state.conversation)) {
373
+ state.conversation = conversation;
374
+ renderConversation();
375
+ }
376
+ state.threadPages = 1;
377
+ state.replyPages = 1;
378
+ state.firstPage = page;
379
+ state.firstConversation = conversation;
380
+ setChanged(false);
381
+ if (focusKey && !focused.isConnected) {
382
+ const target = [...document.querySelectorAll(`[data-${focusKey[0]}]`)].find(
383
+ (element) => element.dataset[focusKey[0]] === focusKey[1],
384
+ );
385
+ target?.focus({ preventScroll: true });
386
+ }
387
+ }
388
+
389
+ $("boards").onchange = () => run(() => loadBoard($("boards").value));
390
+ $("author").onchange = () =>
391
+ run(async () => {
392
+ state.author = $("author").value;
393
+ await loadList();
394
+ });
395
+ $("search-form").onsubmit = (event) => {
396
+ event.preventDefault();
397
+ run(async () => {
398
+ state.query = $("search").value.trim();
399
+ await loadList();
400
+ });
401
+ };
402
+ $("refresh").onclick = () => run(() => refresh(true));
403
+ $("back").onclick = () => {
404
+ document.body.classList.remove("detail-open");
405
+ const selected = [...$("threads").querySelectorAll("a")].find(
406
+ (link) => link.dataset.thread === state.thread,
407
+ );
408
+ (selected || $("search")).focus();
409
+ };
410
+ $("more-threads").onclick = () =>
411
+ run(async () => {
412
+ if (!state.page.nextCursor) return;
413
+ const page = await api("/api/threads", {
414
+ ...threadParams(),
415
+ cursor: state.page.nextCursor,
416
+ });
417
+ if (state.threadPages === 1) state.firstPage = state.page;
418
+ state.page = {
419
+ threads: [
420
+ ...state.page.threads,
421
+ ...page.threads.filter(
422
+ (thread) => !state.page.threads.some((old) => old.id === thread.id),
423
+ ),
424
+ ],
425
+ nextCursor: page.nextCursor,
426
+ };
427
+ state.threadPages++;
428
+ renderThreads();
429
+ });
430
+ $("more-replies").onclick = () =>
431
+ run(async () => {
432
+ if (!state.conversation.nextCursor) return;
433
+ const page = await api("/api/thread", {
434
+ boardId: state.board,
435
+ id: state.thread,
436
+ cursor: state.conversation.nextCursor,
437
+ });
438
+ if (state.replyPages === 1) state.firstConversation = state.conversation;
439
+ const replies = page.replies.filter(
440
+ (post) => !state.conversation.replies.some((old) => old.id === post.id),
441
+ );
442
+ state.conversation = {
443
+ ...state.conversation,
444
+ replies: [...state.conversation.replies, ...replies],
445
+ nextCursor: page.nextCursor,
446
+ };
447
+ state.replyPages++;
448
+ $("posts").append(...replies.map((post) => renderPost(post, false)));
449
+ $("more-replies").hidden = !page.nextCursor;
450
+ });
451
+
452
+ async function start() {
453
+ if (!token)
454
+ throw new Error(
455
+ "This link has no viewing key. Open the viewer again with /board.",
456
+ );
457
+ const index = await api("/api/boards");
458
+ $("session").textContent = index.context.sessionName || "Current session";
459
+ $("session").title = index.context.folder;
460
+ $("boards").replaceChildren(
461
+ ...index.boards.map((board) => {
462
+ const label =
463
+ board.id === index.context.boardId
464
+ ? "Current session"
465
+ : `${board.createdAt ? date(board.createdAt) : "Saved board"} · ${board.id.slice(0, 8)}`;
466
+ const option = node("option", "", label);
467
+ option.value = board.id;
468
+ option.title = board.id;
469
+ return option;
470
+ }),
471
+ );
472
+ const requestedBoard = fragment.get("board");
473
+ const board = index.boards.some((item) => item.id === requestedBoard)
474
+ ? requestedBoard
475
+ : index.context.boardId;
476
+ $("boards").value = board;
477
+ await loadBoard(board);
478
+ const requestedThread = fragment.get("thread");
479
+ if (requestedThread) await openThread(requestedThread);
480
+ }
481
+ run(start);
482
+ setInterval(() => {
483
+ if (!document.hidden && $("live").checked && state.board && !busy && !changed)
484
+ run(() => refresh());
485
+ }, 5000);
@@ -0,0 +1,39 @@
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
+ <meta name="referrer" content="no-referrer">
7
+ <title>Board</title>
8
+ <link rel="stylesheet" href="/style.css">
9
+ <script type="module" src="/app.js"></script>
10
+ </head>
11
+ <body>
12
+ <header class="masthead">
13
+ <h1>Board</h1>
14
+ <div class="live-controls"><label><input id="live" type="checkbox" checked> Live</label><button id="refresh" type="button">Refresh</button></div>
15
+ </header>
16
+ <div id="status" role="status" aria-live="polite"></div>
17
+ <div class="workspace">
18
+ <aside class="rail" aria-label="Board navigation">
19
+ <label for="boards">Board</label><select id="boards"></select>
20
+ <p id="session" class="context"></p>
21
+ <nav id="channels" aria-label="Channels"></nav>
22
+ <div class="agent-filter"><label for="author">Agent</label><select id="author"><option value="">All agents</option></select></div>
23
+ <details id="watching"><summary>Watching</summary><div id="subscriptions"></div></details>
24
+ </aside>
25
+ <section class="index" aria-labelledby="list-title">
26
+ <div class="index-head"><h2 id="list-title">All activity</h2>
27
+ <form id="search-form" role="search"><label for="search">Search posts</label><div class="search-row"><input id="search" type="search"><button type="submit">Search</button></div></form>
28
+ </div>
29
+ <div id="threads" class="thread-list"></div>
30
+ <button id="more-threads" class="page-button" hidden>More threads</button>
31
+ </section>
32
+ <main id="conversation" class="conversation" aria-label="Conversation">
33
+ <button id="back" type="button">Back to threads</button>
34
+ <div id="posts"></div>
35
+ <button id="more-replies" class="page-button" hidden>More replies</button>
36
+ </main>
37
+ </div>
38
+ </body>
39
+ </html>