@unifedev/thread-pages 0.3.2

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/home.ts ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * The default home page.
3
+ *
4
+ * Written by `bb thread-page home`, and an ordinary Thread Page afterwards: the
5
+ * owning agent can redesign any part of it on request. It is long because it is
6
+ * written once and read rarely, and because a weak default would push every user
7
+ * into rebuilding it.
8
+ *
9
+ * The design decision that matters: sessions are grouped, and a group is not
10
+ * hard-wired to a project. A group is a label, a look, and a set of project ids,
11
+ * stored in this page's own scoped storage. The default grouping is one group
12
+ * per project because that is what a new user expects, but the same page renders
13
+ * "Work", "Side projects", or anything else the user asks for, and a project can
14
+ * appear in more than one group. Each group carries its own `data-world`, which
15
+ * re-resolves the design tokens for that subtree — so a project genuinely looks
16
+ * different without a second document or a second stylesheet.
17
+ */
18
+ export const DEFAULT_HOME_BODY = String.raw` <header class="brief-head">
19
+ <h1>Sessions</h1>
20
+ <p class="brief-meta">
21
+ <span data-count>Loading…</span>
22
+ <span data-updated></span>
23
+ </p>
24
+ </header>
25
+
26
+ <main>
27
+ <div class="toolbar">
28
+ <input type="search" data-filter placeholder="Filter sessions…" aria-label="Filter sessions">
29
+ <label class="inline"><input type="checkbox" data-show-idle checked> Show idle</label>
30
+ <label class="inline"><input type="checkbox" data-show-archived> Show archived</label>
31
+ <button type="button" data-refresh>Refresh</button>
32
+ </div>
33
+
34
+ <p data-error class="error" hidden></p>
35
+ <div data-groups></div>
36
+ </main>
37
+ `;
38
+
39
+ export const DEFAULT_HOME_STYLE = String.raw`@scope (main) {
40
+ .toolbar {
41
+ display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
42
+ }
43
+ .toolbar input[type="search"] {
44
+ flex: 1 1 14rem; min-width: 0; margin: 0; padding: 0.45rem 0.65rem;
45
+ font: inherit; font-size: 0.9rem; color: var(--ink); background: var(--surface);
46
+ border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * 0.7);
47
+ }
48
+ .toolbar .inline {
49
+ display: inline-flex; align-items: center; gap: 0.4rem;
50
+ font-size: 0.85rem; font-weight: 400; color: var(--ink-2); white-space: nowrap;
51
+ }
52
+ .toolbar .inline input { margin: 0; accent-color: var(--accent); }
53
+ .toolbar button {
54
+ font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer;
55
+ color: var(--accent); background: transparent;
56
+ border: var(--rule-w) solid var(--rule);
57
+ border-radius: calc(var(--radius) * 0.7); padding: 0.4rem 0.8rem;
58
+ }
59
+ .toolbar button:hover { border-color: var(--accent); }
60
+
61
+ .error {
62
+ margin-top: 1rem; padding: 0.7rem 0.9rem; color: var(--flag);
63
+ border: var(--rule-w) solid var(--flag); border-radius: calc(var(--radius) * 0.7);
64
+ }
65
+
66
+ /* A group carries its own data-world, so every token below re-resolves. */
67
+ .group {
68
+ margin-top: 1.6rem; padding: 1.1rem 1.2rem 1.2rem;
69
+ background: var(--surface); color: var(--ink);
70
+ border: var(--rule-w) solid var(--rule); border-radius: var(--radius);
71
+ box-shadow: var(--shadow);
72
+ }
73
+ .group > summary {
74
+ cursor: pointer; list-style: none; display: flex; flex-wrap: wrap;
75
+ align-items: baseline; gap: 0.6rem;
76
+ }
77
+ .group > summary::-webkit-details-marker { display: none; }
78
+ .group > summary::before {
79
+ content: "▸"; color: var(--ink-3); font-size: 0.8em;
80
+ transition: transform var(--dur) var(--ease);
81
+ }
82
+ .group[open] > summary::before { transform: rotate(90deg); }
83
+ .group h2 {
84
+ margin: 0; font-family: var(--font-head); font-size: 1.08rem;
85
+ font-weight: var(--head-weight); letter-spacing: var(--head-track);
86
+ color: var(--ink);
87
+ }
88
+ .group .meta {
89
+ margin-left: auto; font-size: 0.75rem; color: var(--ink-3);
90
+ font-variant-numeric: tabular-nums; text-transform: var(--label-case);
91
+ letter-spacing: var(--caps-track);
92
+ }
93
+
94
+ .rows { margin-top: 1rem; display: grid; gap: 0.5rem; }
95
+ .row {
96
+ display: grid; grid-template-columns: 1fr auto; gap: 0.5rem 0.9rem;
97
+ align-items: center; padding: 0.6rem 0.75rem;
98
+ border: var(--rule-w) solid var(--rule-soft);
99
+ border-radius: calc(var(--radius) * 0.8); background: var(--bg);
100
+ }
101
+ .row .name { font-weight: 600; color: var(--ink); overflow-wrap: anywhere; }
102
+ .row .sub {
103
+ margin-top: 0.15rem; font-size: 0.76rem; color: var(--ink-3);
104
+ display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
105
+ }
106
+ .dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--ink-3); flex: none; }
107
+ .dot[data-state="active"] { background: var(--ok); }
108
+ .dot[data-state="failed"] { background: var(--flag); }
109
+ .dot[data-state="waiting"] { background: var(--accent); }
110
+ @media (prefers-reduced-motion: no-preference) {
111
+ .dot[data-state="active"] { animation: home-pulse 1.4s ease-in-out infinite; }
112
+ }
113
+ @keyframes home-pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.3 } }
114
+
115
+ .acts { display: flex; flex-wrap: wrap; gap: 0.35rem; justify-content: flex-end; }
116
+ .acts button {
117
+ font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer;
118
+ color: var(--accent); background: transparent;
119
+ border: var(--rule-w) solid var(--rule);
120
+ border-radius: calc(var(--radius) * 0.6); padding: 0.28rem 0.6rem;
121
+ }
122
+ .acts button:hover:not(:disabled) { border-color: var(--accent); }
123
+ .acts button:disabled { opacity: 0.5; cursor: default; }
124
+ .acts button[data-danger]:hover:not(:disabled) { color: var(--flag); border-color: var(--flag); }
125
+
126
+ .empty { margin-top: 0.9rem; font-size: 0.85rem; color: var(--ink-3); }
127
+
128
+ .starter { margin-top: 1rem; }
129
+ .starter summary {
130
+ cursor: pointer; font-size: 0.82rem; font-weight: 600; color: var(--accent);
131
+ }
132
+ .starter .fields { margin-top: 0.7rem; display: grid; gap: 0.6rem; }
133
+ .starter textarea, .starter select {
134
+ width: 100%; margin: 0; padding: 0.5rem 0.65rem; font: inherit; font-size: 0.9rem;
135
+ color: var(--ink); background: var(--bg);
136
+ border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * 0.7);
137
+ }
138
+ .starter textarea { min-height: 4rem; resize: vertical; line-height: 1.5; }
139
+ .starter .go {
140
+ justify-self: start; font: inherit; font-size: 0.85rem; font-weight: 640;
141
+ cursor: pointer; color: var(--bg); background: var(--accent);
142
+ border: var(--rule-w) solid var(--accent);
143
+ border-radius: calc(var(--radius) * 0.7); padding: 0.45rem 1rem;
144
+ }
145
+ .say { min-height: 1.3em; font-size: 0.78rem; color: var(--ink-3); }
146
+ .say[data-tone="bad"] { color: var(--flag); }
147
+ .say[data-tone="good"] { color: var(--ok); }
148
+
149
+ @media (max-width: 34rem) {
150
+ .row { grid-template-columns: 1fr; }
151
+ .acts { justify-content: flex-start; }
152
+ }
153
+ }`;
154
+
155
+ export const DEFAULT_HOME_SCRIPT = String.raw`(() => {
156
+ "use strict";
157
+ const tp = window.threadPage;
158
+ const $ = (sel, root = document) => root.querySelector(sel);
159
+ const groupsEl = $("[data-groups]");
160
+ const errEl = $("[data-error]");
161
+ const countEl = $("[data-count]");
162
+ const updatedEl = $("[data-updated]");
163
+ const filterEl = $("[data-filter]");
164
+ const idleEl = $("[data-show-idle]");
165
+ const archEl = $("[data-show-archived]");
166
+
167
+ /* Five looks, assigned round-robin so adjacent projects never collide. A
168
+ stored group can name its own. */
169
+ const WORLDS = ["volume", "atrium", "terminal", "bloom", "paper"];
170
+ const STORE_KEY = "home.groups";
171
+
172
+ let projects = [];
173
+ let threads = [];
174
+ let groups = null;
175
+ let open = {};
176
+
177
+ function fail(message) {
178
+ errEl.hidden = false;
179
+ errEl.textContent = message;
180
+ }
181
+
182
+ function ago(ms) {
183
+ if (!ms) return "";
184
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
185
+ if (s < 60) return s + "s ago";
186
+ if (s < 3600) return Math.round(s / 60) + "m ago";
187
+ if (s < 86400) return Math.round(s / 3600) + "h ago";
188
+ return Math.round(s / 86400) + "d ago";
189
+ }
190
+
191
+ function defaultGroups() {
192
+ const used = projects.filter((p) => threads.some((t) => t.projectId === p.id));
193
+ const rest = projects.filter((p) => !used.includes(p));
194
+ return used.concat(rest).map((project, index) => ({
195
+ id: project.id,
196
+ label: project.name,
197
+ world: WORLDS[index % WORLDS.length],
198
+ projectIds: [project.id],
199
+ }));
200
+ }
201
+
202
+ function visible(thread) {
203
+ if (!archEl.checked && thread.archived) return false;
204
+ if (!idleEl.checked && thread.status === "idle" && !thread.archived) return false;
205
+ const needle = filterEl.value.trim().toLowerCase();
206
+ if (needle && !thread.title.toLowerCase().includes(needle)) return false;
207
+ return true;
208
+ }
209
+
210
+ function el(tag, className, text) {
211
+ const node = document.createElement(tag);
212
+ if (className) node.className = className;
213
+ if (text !== undefined) node.textContent = text;
214
+ return node;
215
+ }
216
+
217
+ function actionButton(label, danger, run) {
218
+ const button = el("button", null, label);
219
+ button.type = "button";
220
+ if (danger) button.dataset.danger = "";
221
+ button.addEventListener("click", async () => {
222
+ const original = button.textContent;
223
+ button.disabled = true;
224
+ try {
225
+ button.textContent = await run();
226
+ } catch (error) {
227
+ /* A declined confirmation is a normal outcome, not a failure. */
228
+ button.textContent = error && error.code === "cancelled" ? original : "failed";
229
+ if (error && error.code !== "cancelled") {
230
+ fail((error.code || "error") + ": " + (error.message || ""));
231
+ }
232
+ } finally {
233
+ window.setTimeout(() => {
234
+ button.disabled = false;
235
+ button.textContent = original;
236
+ }, 1600);
237
+ }
238
+ });
239
+ return button;
240
+ }
241
+
242
+ function renderRow(thread) {
243
+ const row = el("div", "row");
244
+ const left = el("div");
245
+ left.append(el("div", "name", thread.title || "Untitled"));
246
+ const sub = el("div", "sub");
247
+ const dot = el("span", "dot");
248
+ dot.dataset.state = thread.status;
249
+ sub.append(dot, el("span", null, thread.status));
250
+ if (thread.archived) sub.append(el("span", null, "· archived"));
251
+ if (thread.page.available) sub.append(el("span", null, "· has page"));
252
+ sub.append(el("span", null, "· " + ago(thread.updatedAtMs)));
253
+ left.append(sub);
254
+
255
+ const acts = el("div", "acts");
256
+ if (thread.page.available) {
257
+ acts.append(actionButton("Page", false, async () => {
258
+ await tp.invoke("threads.openPage", { threadId: thread.id });
259
+ return "opened";
260
+ }));
261
+ }
262
+ acts.append(actionButton("bb", false, async () => {
263
+ await tp.invoke("threads.openBb", { threadId: thread.id });
264
+ return "opened";
265
+ }));
266
+ acts.append(actionButton("Prompt", false, async () => {
267
+ const prompt = window.prompt("Send to “" + thread.title + "”:");
268
+ if (!prompt) return "Prompt";
269
+ const result = await tp.invoke("threads.continue", {
270
+ threadId: thread.id,
271
+ prompt,
272
+ });
273
+ return result.delivery;
274
+ }));
275
+ if (thread.status === "active") {
276
+ acts.append(actionButton("Stop", true, async () => {
277
+ await tp.invoke("threads.stop", { threadId: thread.id });
278
+ return "stopped";
279
+ }));
280
+ }
281
+ if (!thread.archived) {
282
+ acts.append(actionButton("Archive", true, async () => {
283
+ await tp.invoke("threads.archive", { threadId: thread.id });
284
+ await load();
285
+ return "archived";
286
+ }));
287
+ }
288
+ row.append(left, acts);
289
+ return row;
290
+ }
291
+
292
+ function renderStarter(group) {
293
+ const box = el("details", "starter");
294
+ box.append(el("summary", null, "Start a session here"));
295
+ const fields = el("div", "fields");
296
+
297
+ const ids = group.projectIds.filter((id) => projects.some((p) => p.id === id));
298
+ let projectId = ids[0];
299
+ if (ids.length > 1) {
300
+ const select = document.createElement("select");
301
+ for (const id of ids) {
302
+ const project = projects.find((p) => p.id === id);
303
+ const option = document.createElement("option");
304
+ option.value = id;
305
+ option.textContent = project ? project.name : id;
306
+ select.append(option);
307
+ }
308
+ select.addEventListener("change", () => { projectId = select.value; });
309
+ fields.append(select);
310
+ }
311
+
312
+ const text = document.createElement("textarea");
313
+ text.placeholder = "What should the new session do?";
314
+ const go = el("button", "go", "Start");
315
+ go.type = "button";
316
+ const say = el("p", "say");
317
+
318
+ go.addEventListener("click", async () => {
319
+ const prompt = text.value.trim();
320
+ if (!prompt) { say.dataset.tone = "bad"; say.textContent = "Say what it should do."; return; }
321
+ if (!projectId) { say.dataset.tone = "bad"; say.textContent = "This group has no project."; return; }
322
+ go.disabled = true;
323
+ say.dataset.tone = "";
324
+ say.textContent = "Waiting for confirmation…";
325
+ try {
326
+ await tp.invoke("threads.spawn", { projectId, prompt });
327
+ say.dataset.tone = "good";
328
+ say.textContent = "Started.";
329
+ text.value = "";
330
+ await load();
331
+ } catch (error) {
332
+ if (error && error.code === "cancelled") {
333
+ say.dataset.tone = "";
334
+ say.textContent = "Cancelled.";
335
+ } else {
336
+ say.dataset.tone = "bad";
337
+ say.textContent = (error && error.message) || "Could not start it.";
338
+ }
339
+ } finally {
340
+ go.disabled = false;
341
+ }
342
+ });
343
+
344
+ fields.append(text, go, say);
345
+ box.append(fields);
346
+ return box;
347
+ }
348
+
349
+ function render() {
350
+ groupsEl.textContent = "";
351
+ const list = groups || defaultGroups();
352
+ let shown = 0;
353
+
354
+ for (const group of list) {
355
+ const mine = threads
356
+ .filter((t) => group.projectIds.includes(t.projectId))
357
+ .filter(visible)
358
+ .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
359
+ const active = mine.filter((t) => t.status === "active").length;
360
+
361
+ const box = document.createElement("details");
362
+ box.className = "group";
363
+ /* This is what gives each group its own look. */
364
+ if (group.world) box.dataset.world = group.world;
365
+ box.open = open[group.id] !== undefined ? open[group.id] : mine.length > 0;
366
+ box.addEventListener("toggle", () => { open[group.id] = box.open; });
367
+
368
+ const summary = document.createElement("summary");
369
+ summary.append(el("h2", null, group.label));
370
+ summary.append(
371
+ el("span", "meta", mine.length + (active ? " · " + active + " active" : "")),
372
+ );
373
+ box.append(summary);
374
+
375
+ if (mine.length) {
376
+ const rows = el("div", "rows");
377
+ for (const thread of mine) rows.append(renderRow(thread));
378
+ box.append(rows);
379
+ } else {
380
+ box.append(el("p", "empty", "Nothing here right now."));
381
+ }
382
+ box.append(renderStarter(group));
383
+ groupsEl.append(box);
384
+ shown += mine.length;
385
+ }
386
+
387
+ countEl.textContent =
388
+ shown + " of " + threads.length + " session" + (threads.length === 1 ? "" : "s");
389
+ updatedEl.textContent = "updated " + new Date().toLocaleTimeString();
390
+ }
391
+
392
+ async function load() {
393
+ try {
394
+ errEl.hidden = true;
395
+ const [projectList, snapshot, stored] = await Promise.all([
396
+ tp.invoke("projects.list", {}),
397
+ tp.invoke("threads.snapshot", { limit: 200, includeArchived: true }),
398
+ tp.invoke("storage.get", { key: STORE_KEY }).catch(() => ({ found: false })),
399
+ ]);
400
+ projects = projectList.projects;
401
+ threads = snapshot.threads;
402
+ groups = stored && stored.found && Array.isArray(stored.value) ? stored.value : null;
403
+ render();
404
+ } catch (error) {
405
+ fail((error && error.message) || "Could not load sessions.");
406
+ }
407
+ }
408
+
409
+ for (const control of [filterEl, idleEl, archEl]) {
410
+ control.addEventListener("input", () => { if (threads.length) render(); });
411
+ }
412
+ $("[data-refresh]").addEventListener("click", () => { void load(); });
413
+
414
+
415
+ void load();
416
+ /* Cheap and visible-only: the shell already reloads the page when the agent
417
+ saves, so this only keeps statuses fresh while you are looking. */
418
+ tp.watch("thread.activity", { limit: 1 }, () => { void load(); }, { intervalMs: 15000 });
419
+ })();`;
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@unifedev/thread-pages",
3
+ "version": "0.3.2",
4
+ "description": "Thread Pages for bb: every agent session gets one directly editable HTML page you can answer from, on any device.",
5
+ "license": "MIT",
6
+ "author": "Bartosz",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/unifedev/bb-thread-pages.git"
10
+ },
11
+ "homepage": "https://github.com/unifedev/bb-thread-pages",
12
+ "bugs": {
13
+ "url": "https://github.com/unifedev/bb-thread-pages/issues"
14
+ },
15
+ "keywords": [
16
+ "bb",
17
+ "bb-plugin",
18
+ "unife",
19
+ "thread-pages",
20
+ "agents",
21
+ "html"
22
+ ],
23
+ "type": "module",
24
+ "private": false,
25
+ "engines": {
26
+ "bb": ">=0.40",
27
+ "bbPluginSdk": ">=0.4.21"
28
+ },
29
+ "scripts": {
30
+ "build": "bb plugin build",
31
+ "test": "vitest run",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "devDependencies": {
35
+ "@get-bb/plugin-sdk": "0.4.47",
36
+ "@types/node": "^22.10.0",
37
+ "bb-app": "0.40.0",
38
+ "cron-parser": "^5.5.0",
39
+ "typescript": "^5.7.2",
40
+ "vitest": "^3.0.0"
41
+ },
42
+ "bb": {
43
+ "name": "Thread Pages",
44
+ "description": "Host one directly editable HTML mini-app for each thread.",
45
+ "branding": {
46
+ "icon": "FileText"
47
+ },
48
+ "server": "./server.ts",
49
+ "skills": []
50
+ },
51
+ "dependencies": {
52
+ "parse5": "^8.0.1"
53
+ },
54
+ "files": [
55
+ "*.ts",
56
+ "!*.test.ts",
57
+ "dist/server.js",
58
+ "dist/server.meta.json",
59
+ "docs/",
60
+ "ARCHITECTURE.md",
61
+ "PLUGIN_OVERVIEW.md",
62
+ "README.md",
63
+ "tsconfig.json",
64
+ "package-lock.json"
65
+ ]
66
+ }