@unifedev/thread-pages 0.3.2 → 1.1.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/README.md +77 -129
- package/dist/server.js +11779 -11801
- package/dist/server.meta.json +2 -2
- package/docs/B1-OWN-FILES.md +133 -0
- package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
- package/docs/UPGRADING.md +53 -0
- package/package.json +26 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +450 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +73 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +9 -11
- package/src/agent/starter-hub.ts +217 -0
- package/src/bb/activity.ts +59 -0
- package/src/bb/bb-host.ts +280 -0
- package/src/bb/public-origin.ts +45 -0
- package/src/config/settings.ts +82 -0
- package/src/domain/capabilities/contract.ts +48 -0
- package/src/domain/capabilities/index.ts +10 -0
- package/src/domain/capabilities/protocol.ts +113 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/renamed.ts +34 -0
- package/src/domain/capabilities/schema.ts +198 -0
- package/src/domain/capabilities/specs.ts +479 -0
- package/src/domain/eligibility.ts +43 -0
- package/src/domain/errors.ts +116 -0
- package/src/domain/html/document.ts +109 -0
- package/src/domain/html/escape.ts +16 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/json/canonical.ts +19 -0
- package/src/domain/json/strict-json.ts +139 -0
- package/src/domain/limits.ts +98 -0
- package/src/domain/rate-limit.ts +64 -0
- package/src/domain/revision.ts +27 -0
- package/src/domain/submissions/idempotency.ts +59 -0
- package/src/domain/submissions/message.ts +42 -0
- package/src/domain/submissions/parse.ts +105 -0
- package/src/domain/tokens/action-token.ts +52 -0
- package/src/domain/tokens/confirmation.ts +99 -0
- package/src/domain/tokens/mac.ts +50 -0
- package/src/generated/kernel-runtime.ts +3 -0
- package/src/generated/shell-runtime.ts +3 -0
- package/src/host/contract.ts +65 -0
- package/src/host/types.ts +89 -0
- package/src/pages/inline.ts +277 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +170 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +81 -0
- package/src/runtime/kernel/anchors.ts +45 -0
- package/src/runtime/kernel/api.ts +15 -0
- package/src/runtime/kernel/bridge-client.ts +148 -0
- package/src/runtime/kernel/dirty.ts +51 -0
- package/src/runtime/kernel/forms.ts +114 -0
- package/src/runtime/kernel/install.ts +156 -0
- package/src/runtime/kernel/labels.ts +98 -0
- package/src/runtime/kernel/main.ts +6 -0
- package/src/runtime/kernel/readonly.ts +75 -0
- package/src/runtime/shared/protocol.ts +125 -0
- package/src/runtime/shell/confirm.ts +70 -0
- package/src/runtime/shell/install.ts +79 -0
- package/src/runtime/shell/main.ts +12 -0
- package/src/runtime/shell/navigate.ts +64 -0
- package/src/runtime/shell/poll.ts +125 -0
- package/src/runtime/shell/relay.ts +185 -0
- package/src/serving/action-request.ts +32 -0
- package/src/serving/bridge/dispatcher.ts +112 -0
- package/src/serving/bridge/handler.ts +37 -0
- package/src/serving/bridge/handlers/index.ts +26 -0
- package/src/serving/bridge/handlers/navigation.ts +43 -0
- package/src/serving/bridge/handlers/reads.ts +186 -0
- package/src/serving/bridge/handlers/writes.ts +175 -0
- package/src/serving/bridge/selection-store.ts +58 -0
- package/src/serving/bridge-route.ts +23 -0
- package/src/serving/context.ts +34 -0
- package/src/serving/document-route.ts +37 -0
- package/src/serving/home-route.ts +23 -0
- package/src/serving/responses.ts +81 -0
- package/src/serving/routes.ts +26 -0
- package/src/serving/session-access.ts +22 -0
- package/src/serving/shell-html.ts +77 -0
- package/src/serving/shell-route.ts +51 -0
- package/src/serving/signing-key.ts +25 -0
- package/src/serving/submit-route.ts +47 -0
- package/src/serving/upload-route.ts +46 -0
- package/tsconfig.json +10 -6
- package/ARCHITECTURE.md +0 -230
- package/PLUGIN_OVERVIEW.md +0 -83
- package/authoring.ts +0 -368
- package/bridge.ts +0 -1721
- package/docs/MODEL.md +0 -211
- package/docs/ROADMAP.md +0 -96
- package/home.ts +0 -419
- package/page.ts +0 -782
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standing instruction, injected into eligible new sessions when the
|
|
3
|
+
* `agentInstructions` setting is on. It is paid for by every session, so it
|
|
4
|
+
* holds only what changes behaviour; the host truncates instructions at
|
|
5
|
+
* 4096 characters, so it must stay under that. spec R6.14–R6.17
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_AGENT_INSTRUCTION = `# The page is the conversation
|
|
8
|
+
|
|
9
|
+
The reader does not read chat. Every turn you write or update one HTML page;
|
|
10
|
+
they read it and answer from inside it, and the answer arrives as your next
|
|
11
|
+
message. Chat carries the link and one line. A page they cannot answer from is
|
|
12
|
+
a dead end.
|
|
13
|
+
|
|
14
|
+
Start every turn with \`bb thread-page init\`. It prints the page path and the
|
|
15
|
+
link. Read an existing page before editing it; saving publishes it at once and
|
|
16
|
+
an open page reloads itself. If init says SKIP, this session is a helper:
|
|
17
|
+
answer in chat and stay off the page.
|
|
18
|
+
|
|
19
|
+
## Every page ends with a way to answer
|
|
20
|
+
|
|
21
|
+
Any <form> is wired automatically; nothing is required and blank is a real
|
|
22
|
+
answer. Plain semantic HTML is already styled: <fieldset><legend> names a
|
|
23
|
+
group, a wrapping <label> names one control, <small> is a hint, several
|
|
24
|
+
<button name value> give one-click answers.
|
|
25
|
+
|
|
26
|
+
Asking well is most of the work: buttons and radios for decisions, checkboxes
|
|
27
|
+
for multi-select, free text only where the answer is genuinely open. A scale
|
|
28
|
+
needs a meaning at both ends, never a bare 1-to-5. Always leave one open field
|
|
29
|
+
for what you failed to anticipate: a form that permits only the answers you
|
|
30
|
+
expect takes the decision away from the reader.
|
|
31
|
+
|
|
32
|
+
## What belongs on the page
|
|
33
|
+
|
|
34
|
+
What you did, at the level they could explain to someone else; decisions that
|
|
35
|
+
are theirs, with the options and your recommendation; what only they can
|
|
36
|
+
supply; anything a wrong assumption of yours would make costly. Report
|
|
37
|
+
failures, skipped steps and your own mistakes plainly. Conclusion first.
|
|
38
|
+
|
|
39
|
+
## One agent, one page
|
|
40
|
+
|
|
41
|
+
Your page is yours alone: you never read or write another agent's page. To
|
|
42
|
+
create another interface — a dashboard, a console, a second view — start a
|
|
43
|
+
session with instructions to build it; that agent writes its own page. Link to
|
|
44
|
+
it, or suggest making it home. A page that should stay put is one whose forms
|
|
45
|
+
start fresh sessions instead of messaging you: nothing then asks you to
|
|
46
|
+
rewrite it. If you want another agent's page changed, talk to that agent.
|
|
47
|
+
|
|
48
|
+
## The home page
|
|
49
|
+
|
|
50
|
+
One page is home; every other page links back to it in chrome you never
|
|
51
|
+
write. init says whether one exists. When the reader asks for one place to
|
|
52
|
+
see and steer their sessions, build it in a session dedicated to it: run
|
|
53
|
+
\`bb thread-page home\` there and follow \`bb thread-page guide\` §The home page.
|
|
54
|
+
|
|
55
|
+
## More
|
|
56
|
+
|
|
57
|
+
A page that needs more than prose and a form — files beside it, a chart, live
|
|
58
|
+
session state, starting or steering sessions, links — runs
|
|
59
|
+
\`bb thread-page guide\` first.`;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { escapeHtml } from "../../domain/html/escape.ts";
|
|
2
|
+
import { THEME_CSS } from "./theme-css.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The document a new page starts from: complete, valid, with a working
|
|
6
|
+
* captured form, its own stylesheet, and a short comment stating the
|
|
7
|
+
* conventions while the agent is already reading the file. spec R6.18–R6.23
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_PAGE_SEED = `<!doctype html>
|
|
10
|
+
<html lang="en" data-theme="volume" data-mode="system" data-atmos="on">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8">
|
|
13
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
14
|
+
<title>{{TITLE}}</title>
|
|
15
|
+
<style>${THEME_CSS}</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div class="atmosphere" aria-hidden="true"></div>
|
|
19
|
+
<div class="wrap">
|
|
20
|
+
|
|
21
|
+
<header class="brief-head">
|
|
22
|
+
<h1>{{TITLE}}</h1>
|
|
23
|
+
<p class="brief-meta"><span>{{DATE}}</span></p>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<!--
|
|
27
|
+
Everything below is yours: this whole file, including the stylesheet in
|
|
28
|
+
the head. Rewrite it however the task needs. Two things the file cannot
|
|
29
|
+
tell you, because they are behaviour rather than markup:
|
|
30
|
+
|
|
31
|
+
1. Every form element in this document is captured and delivered to this
|
|
32
|
+
session as a message. That includes a dialog form you only meant as a
|
|
33
|
+
local confirm; put data-thread-page-manual on any form that is not
|
|
34
|
+
meant to answer. Nothing is required and blank is a real answer.
|
|
35
|
+
2. window.prompt, alert, confirm and window.open do nothing here — the
|
|
36
|
+
sandbox silences them. Build the input into the page instead.
|
|
37
|
+
|
|
38
|
+
The look is three attributes on the html element. data-theme: paper,
|
|
39
|
+
terminal, atrium, volume or bloom. data-mode: system, light or dark.
|
|
40
|
+
data-atmos: on or off.
|
|
41
|
+
|
|
42
|
+
If this page should stay put — a dashboard, a console, a page nobody
|
|
43
|
+
should have to rewrite — delete the reply form below and let its buttons
|
|
44
|
+
start fresh sessions instead. See the guide.
|
|
45
|
+
|
|
46
|
+
Everything else — files beside this one, charts, live session state,
|
|
47
|
+
starting sessions, links, limits — is in: bb thread-page guide
|
|
48
|
+
|
|
49
|
+
This comment deliberately names no HTML tags. An earlier version spelled
|
|
50
|
+
them out, and every agent that edited its page by string surgery found
|
|
51
|
+
tags here that were not in the document. Keep it that way.
|
|
52
|
+
-->
|
|
53
|
+
<main>
|
|
54
|
+
<p>Replace this with what changed and what you need from the reader.</p>
|
|
55
|
+
|
|
56
|
+
<form data-title="{{TITLE}}">
|
|
57
|
+
<label>Reply
|
|
58
|
+
<textarea name="reply" rows="4"></textarea>
|
|
59
|
+
</label>
|
|
60
|
+
<button name="action" value="Reply">Reply</button>
|
|
61
|
+
</form>
|
|
62
|
+
</main>
|
|
63
|
+
|
|
64
|
+
</div>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
/** Applies the seed's substitutions; both are escaped. spec R6.21 */
|
|
70
|
+
export function renderSeed(template: string, title: string, now: Date = new Date()): string {
|
|
71
|
+
const date = now.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
|
|
72
|
+
return template.replaceAll("{{TITLE}}", escapeHtml(title)).replaceAll("{{DATE}}", escapeHtml(date));
|
|
73
|
+
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* A page picks one with data-theme on <html> and needs no class names: every
|
|
7
|
-
* rule keys off semantic structure (fieldset/legend, a wrapping label, small,
|
|
8
|
-
* button), so plain HTML is already styled.
|
|
9
|
-
*
|
|
10
|
-
* This is carried into each new page by the seed rather than injected at render
|
|
11
|
-
* time, so a page owns its own look and a plugin update never restyles work the
|
|
12
|
-
* user already read.
|
|
2
|
+
* The design system carried by the default seed: five worlds, each with a
|
|
3
|
+
* light and a dark palette, keyed off semantic HTML. Reviewed from the
|
|
4
|
+
* prototype, not redesigned (RW-16). A page owns its copy and may change
|
|
5
|
+
* any rule. spec R4.36, R4.37
|
|
13
6
|
*/
|
|
14
7
|
export const THEME_CSS = String.raw`
|
|
15
8
|
:root {
|
|
@@ -44,6 +37,11 @@ export const THEME_CSS = String.raw`
|
|
|
44
37
|
|
|
45
38
|
*, *::before, *::after { box-sizing: border-box; }
|
|
46
39
|
|
|
40
|
+
/* A class rule that sets display outranks the user-agent rule for the
|
|
41
|
+
hidden attribute, so a page that styles .banner with display:flex would
|
|
42
|
+
render a hidden banner as an empty bar. This keeps hidden meaning hidden. */
|
|
43
|
+
[hidden] { display: none !important; }
|
|
44
|
+
|
|
47
45
|
html { -webkit-text-size-adjust: 100%; }
|
|
48
46
|
|
|
49
47
|
body {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The starter home page: the `<main>` an agent drops into its page to get a
|
|
3
|
+
* working session hub. It is the guide's worked example (§The home page) and
|
|
4
|
+
* the shape a person gets when they ask for a home page.
|
|
5
|
+
*
|
|
6
|
+
* Design: dense and attention-first. A sticky bar with search and a view
|
|
7
|
+
* toggle; projects that need the reader first; per project the sessions that
|
|
8
|
+
* need attention plus the five most recent, with "Show 10 more" — the same
|
|
9
|
+
* rule the host's own sidebar uses; one line per session with the state, the
|
|
10
|
+
* title (opens the page, or the session in bb when there is no page), how
|
|
11
|
+
* long ago, and the actions that matter: bb, Stop, Archive.
|
|
12
|
+
*/
|
|
13
|
+
export const STARTER_HUB_MAIN = String.raw`<div class="hub-bar">
|
|
14
|
+
<input type="search" data-search placeholder="Find a session… (press /)" aria-label="Find a session" autocomplete="off">
|
|
15
|
+
<span class="hub-views" role="group" aria-label="View">
|
|
16
|
+
<button type="button" data-view="attention" aria-pressed="true">Needs you</button>
|
|
17
|
+
<button type="button" data-view="all" aria-pressed="false">All</button>
|
|
18
|
+
</span>
|
|
19
|
+
<button type="button" data-refresh title="Refresh">↻</button>
|
|
20
|
+
<span class="hub-meta" data-meta></span>
|
|
21
|
+
</div>
|
|
22
|
+
<p data-error class="needs-you" hidden></p>
|
|
23
|
+
<div data-groups aria-live="polite"></div>
|
|
24
|
+
<p class="hub-foot">Rows follow bb: no archived sessions, sub-agents hidden, five per project then “more”. This page belongs to the session that built it; ask that session to change it.</p>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
/* The hub is a list, so it takes the width the seed's prose does not need. */
|
|
28
|
+
.wrap { max-width: 68rem; }
|
|
29
|
+
@scope (main) {
|
|
30
|
+
.hub-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; padding: .6rem 0; background: var(--bg); border-bottom: var(--rule-w) solid var(--rule); }
|
|
31
|
+
.hub-bar input[type="search"] { flex: 1 1 16rem; min-width: 0; margin: 0; padding: .45rem .6rem; font: inherit; font-size: .92rem; color: var(--ink); background: var(--surface); border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .7); }
|
|
32
|
+
.hub-views { display: inline-flex; border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .7); overflow: hidden; }
|
|
33
|
+
.hub-views button, .hub-bar > button { margin: 0; padding: .4rem .7rem; font: inherit; font-size: .85rem; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 0; border-radius: 0; box-shadow: none; cursor: pointer; }
|
|
34
|
+
.hub-views button[aria-pressed="true"] { color: var(--bg); background: var(--accent); }
|
|
35
|
+
.hub-bar > button[data-refresh] { border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .7); }
|
|
36
|
+
.hub-meta { margin-left: auto; font-size: .78rem; color: var(--ink-3); font-variant-numeric: tabular-nums; }
|
|
37
|
+
.group { margin-top: 1rem; }
|
|
38
|
+
.group-head { display: flex; align-items: baseline; gap: .6rem; padding: .3rem 0; border-bottom: var(--rule-w) solid var(--rule-soft); }
|
|
39
|
+
.group-head h2 { margin: 0; font-size: .95rem; cursor: pointer; }
|
|
40
|
+
.group-head .counts { font-size: .75rem; color: var(--ink-3); font-variant-numeric: tabular-nums; }
|
|
41
|
+
.group-head .counts b { color: var(--ok); font-weight: 600; }
|
|
42
|
+
.group-head .counts i { color: var(--accent); font-style: normal; font-weight: 600; }
|
|
43
|
+
.group-head .counts s { color: var(--flag); text-decoration: none; font-weight: 600; }
|
|
44
|
+
.group-head button { margin: 0 0 0 auto; padding: .15rem .55rem; font: inherit; font-size: .78rem; font-weight: 600; color: var(--accent); background: transparent; border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .6); box-shadow: none; cursor: pointer; }
|
|
45
|
+
.row { display: grid; grid-template-columns: .6rem minmax(0, 1fr) auto auto; gap: .6rem; align-items: center; min-height: 2.1rem; padding: .1rem 0; border-bottom: var(--rule-w) solid var(--rule-soft); }
|
|
46
|
+
.row .dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--rule); }
|
|
47
|
+
.row[data-status="working"] .dot { background: var(--ok); }
|
|
48
|
+
.row[data-status="waiting"] .dot { background: var(--flag); }
|
|
49
|
+
.row[data-status="failed"] .dot { background: var(--flag); box-shadow: 0 0 0 2px color-mix(in srgb, var(--flag) 35%, transparent); }
|
|
50
|
+
.row[data-unread="true"] .dot { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
51
|
+
@media (prefers-reduced-motion: no-preference) { .row[data-status="working"] .dot { animation: hub-pulse 1.4s ease-in-out infinite; } }
|
|
52
|
+
@keyframes hub-pulse { 0%, 100% { opacity: 1 } 50% { opacity: .3 } }
|
|
53
|
+
.row .title { margin: 0; padding: .2rem 0; font: inherit; font-size: .92rem; text-align: left; color: var(--ink); background: transparent; border: 0; border-radius: 0; box-shadow: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
54
|
+
.row[data-unread="true"] .title { font-weight: 700; }
|
|
55
|
+
.row .title:hover, .row .title:focus-visible { color: var(--accent); }
|
|
56
|
+
.row .when { font-size: .75rem; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
57
|
+
.row .when em { font-style: normal; color: var(--flag); }
|
|
58
|
+
.row .acts { display: flex; gap: .3rem; }
|
|
59
|
+
.row .acts button { margin: 0; padding: .1rem .5rem; font: inherit; font-size: .75rem; font-weight: 600; color: var(--ink-2); background: transparent; border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .6); box-shadow: none; cursor: pointer; }
|
|
60
|
+
.row .acts button:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
|
|
61
|
+
.row .acts button[data-danger]:hover:not(:disabled) { color: var(--flag); border-color: var(--flag); }
|
|
62
|
+
.more { margin: .3rem 0 0; padding: .2rem 0; font: inherit; font-size: .78rem; color: var(--accent); background: transparent; border: 0; box-shadow: none; cursor: pointer; }
|
|
63
|
+
.starter { margin: .5rem 0 0; display: grid; gap: .4rem; }
|
|
64
|
+
.starter textarea { width: 100%; min-height: 3.2rem; margin: 0; padding: .5rem .65rem; font: inherit; font-size: .88rem; line-height: 1.5; color: var(--ink); background: var(--surface); border: var(--rule-w) solid var(--rule); border-radius: calc(var(--radius) * .7); resize: vertical; }
|
|
65
|
+
.starter textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
66
|
+
.starter button { justify-self: start; margin: 0; padding: .4rem .9rem; font: inherit; font-size: .85rem; font-weight: 600; color: var(--bg); background: var(--accent); border: var(--rule-w) solid var(--accent); border-radius: calc(var(--radius) * .7); box-shadow: none; cursor: pointer; }
|
|
67
|
+
.starter button:disabled { opacity: .6; cursor: default; }
|
|
68
|
+
.starter .say { font-size: .78rem; color: var(--ink-3); }
|
|
69
|
+
.empty { margin: .6rem 0; font-size: .85rem; color: var(--ink-3); }
|
|
70
|
+
.hub-foot { margin-top: 1.5rem; font-size: .75rem; color: var(--ink-3); }
|
|
71
|
+
@media (max-width: 40rem) { .row { grid-template-columns: .6rem minmax(0, 1fr) auto; } .row .when { display: none; } }
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
|
|
75
|
+
<script>
|
|
76
|
+
(async () => {
|
|
77
|
+
const tp = window.threadPage;
|
|
78
|
+
const $ = (s) => document.querySelector(s);
|
|
79
|
+
const groupsEl = $("[data-groups]"), errorEl = $("[data-error]"), metaEl = $("[data-meta]"), searchEl = $("[data-search]");
|
|
80
|
+
const PER_PROJECT = 5, MORE = 10;
|
|
81
|
+
const el = (tag, text, cls) => { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; };
|
|
82
|
+
const fail = (m) => { errorEl.hidden = false; errorEl.textContent = m; };
|
|
83
|
+
// Needs you: running, waiting on you, or unread — a failed session only until you have looked at it.
|
|
84
|
+
const needsYou = (s) => s.status === "working" || s.status === "waiting" || s.unread;
|
|
85
|
+
const recency = (s) => Math.max(s.attentionAtMs || 0, s.updatedAtMs || 0);
|
|
86
|
+
const ago = (ms) => { const d = Date.now() - ms; if (d < 60e3) return "now"; if (d < 3600e3) return Math.round(d / 60e3) + "m"; if (d < 86400e3) return Math.round(d / 3600e3) + "h"; return Math.round(d / 86400e3) + "d"; };
|
|
87
|
+
|
|
88
|
+
let projects = [], sessions = [], prefs = { view: "attention", collapsed: {} };
|
|
89
|
+
const expanded = {}, starters = new Set();
|
|
90
|
+
|
|
91
|
+
async function loadPrefs() {
|
|
92
|
+
try { const r = await tp.invoke("storage.get", { key: "home.prefs" }); if (r.found && r.value && typeof r.value === "object") prefs = { view: "attention", collapsed: {}, ...r.value }; } catch {}
|
|
93
|
+
}
|
|
94
|
+
const savePrefs = () => tp.invoke("storage.set", { key: "home.prefs", value: prefs }).catch(() => {});
|
|
95
|
+
|
|
96
|
+
async function fetchAll() {
|
|
97
|
+
const out = []; let cursor = null;
|
|
98
|
+
for (let page = 0; page < 5; page += 1) {
|
|
99
|
+
const r = await tp.invoke("sessions.snapshot", cursor ? { limit: 200, cursor } : { limit: 200 });
|
|
100
|
+
out.push(...r.sessions); cursor = r.nextCursor; if (!cursor) break;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function act(label, run, danger) {
|
|
106
|
+
const b = el("button", label); b.type = "button"; if (danger) b.dataset.danger = "";
|
|
107
|
+
b.addEventListener("click", async (e) => { e.stopPropagation(); b.disabled = true;
|
|
108
|
+
try { await run(); } catch (err) { if (err.code !== "cancelled") fail((err.code ? err.code + ": " : "") + err.message); }
|
|
109
|
+
finally { b.disabled = false; } });
|
|
110
|
+
return b;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function row(s) {
|
|
114
|
+
const r = el("div", undefined, "row"); r.dataset.status = s.status; r.dataset.unread = String(!!s.unread);
|
|
115
|
+
const title = el("button", s.title || "(untitled)", "title"); title.type = "button";
|
|
116
|
+
title.title = s.page.available ? "Open its page" : "No page yet — opens the session in bb";
|
|
117
|
+
title.addEventListener("click", () => tp.invoke(s.page.available ? "pages.open" : "sessions.openHost", { sessionId: s.id }).catch((e) => fail(e.message)));
|
|
118
|
+
const when = el("span", undefined, "when");
|
|
119
|
+
if (s.status === "failed") when.append(el("em", "failed · ")); else if (s.status === "waiting") when.append(el("em", "needs you · ")); else if (s.status === "working") when.append(el("em", "working · "));
|
|
120
|
+
when.append(document.createTextNode(ago(recency(s))));
|
|
121
|
+
const acts = el("span", undefined, "acts");
|
|
122
|
+
if (s.page.available) acts.append(act("bb", () => tp.invoke("sessions.openHost", { sessionId: s.id })));
|
|
123
|
+
if (s.status === "working") acts.append(act("Stop", async () => { await tp.invoke("sessions.stop", { sessionId: s.id }); await load(); }, true));
|
|
124
|
+
acts.append(act(s.unread ? "Read" : "Unread", async () => { const r = await tp.invoke("sessions.markRead", { sessionId: s.id, read: !!s.unread }); s.unread = r.unread; render(); }));
|
|
125
|
+
acts.append(act("Archive", async () => { await tp.invoke("sessions.archive", { sessionId: s.id }); sessions = sessions.filter((x) => x.id !== s.id); render(); }, true));
|
|
126
|
+
r.append(el("span", undefined, "dot"), title, when, acts);
|
|
127
|
+
return r;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function starter(project) {
|
|
131
|
+
const box = el("div", undefined, "starter");
|
|
132
|
+
const text = el("textarea"); text.placeholder = "What should the new session in " + project.name + " do? Say what to report and what not to change."; text.setAttribute("aria-label", text.placeholder);
|
|
133
|
+
const say = el("span", undefined, "say");
|
|
134
|
+
const go = act("Start session", async () => {
|
|
135
|
+
const prompt = text.value.trim(); if (!prompt) { say.textContent = "Say what it should do."; return; }
|
|
136
|
+
say.textContent = "Waiting for your confirmation…";
|
|
137
|
+
try { await tp.invoke("sessions.start", { projectId: project.id, prompt }); say.textContent = "Started."; text.value = ""; starters.delete(project.id); await load(); }
|
|
138
|
+
catch (e) { say.textContent = e.code === "cancelled" ? "Nothing started." : e.message; }
|
|
139
|
+
});
|
|
140
|
+
box.append(text, go, say);
|
|
141
|
+
return box;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function render() {
|
|
145
|
+
const q = searchEl.value.trim().toLowerCase();
|
|
146
|
+
const view = q ? "all" : prefs.view;
|
|
147
|
+
groupsEl.textContent = "";
|
|
148
|
+
let shown = 0, attention = 0;
|
|
149
|
+
const byProject = new Map(projects.map((p) => [p.id, []]));
|
|
150
|
+
for (const s of sessions) { if (byProject.has(s.projectId)) byProject.get(s.projectId).push(s); }
|
|
151
|
+
const order = projects.slice().sort((a, b) => {
|
|
152
|
+
const A = byProject.get(a.id), B = byProject.get(b.id);
|
|
153
|
+
const na = A.filter(needsYou).length, nb = B.filter(needsYou).length;
|
|
154
|
+
if ((na > 0) !== (nb > 0)) return na > 0 ? -1 : 1;
|
|
155
|
+
return Math.max(0, ...B.map(recency)) - Math.max(0, ...A.map(recency));
|
|
156
|
+
});
|
|
157
|
+
for (const project of order) {
|
|
158
|
+
const all = byProject.get(project.id).sort((a, b) => recency(b) - recency(a));
|
|
159
|
+
attention += all.filter(needsYou).length;
|
|
160
|
+
let list = q ? all.filter((s) => (s.title || "").toLowerCase().includes(q) || project.name.toLowerCase().includes(q)) : view === "attention" ? all.filter(needsYou) : all;
|
|
161
|
+
if (view === "attention" && !q && list.length === 0) continue;
|
|
162
|
+
const group = el("section", undefined, "group");
|
|
163
|
+
const head = el("div", undefined, "group-head");
|
|
164
|
+
const h = el("h2", project.name); h.title = "Collapse or expand";
|
|
165
|
+
h.addEventListener("click", () => { prefs.collapsed[project.id] = !prefs.collapsed[project.id]; savePrefs(); render(); });
|
|
166
|
+
const counts = el("span", undefined, "counts");
|
|
167
|
+
const w = all.filter((s) => s.status === "working").length, u = all.filter((s) => s.unread).length, f = all.filter((s) => s.status === "waiting" || (s.status === "failed" && s.unread)).length;
|
|
168
|
+
counts.append(document.createTextNode(all.length + " "));
|
|
169
|
+
if (w) counts.append(el("b", w + " working ")); if (u) counts.append(el("i", u + " unread ")); if (f) counts.append(el("s", f + " need you"));
|
|
170
|
+
const add = act("+ New", async () => { if (starters.has(project.id)) starters.delete(project.id); else starters.add(project.id); render(); });
|
|
171
|
+
head.append(h, counts, add);
|
|
172
|
+
group.append(head);
|
|
173
|
+
if (!prefs.collapsed[project.id]) {
|
|
174
|
+
let visible = list;
|
|
175
|
+
if (view === "all" && !q) { const limit = PER_PROJECT + (expanded[project.id] || 0); visible = list.filter((s, i) => i < limit || needsYou(s)); }
|
|
176
|
+
for (const s of visible) group.append(row(s));
|
|
177
|
+
if (visible.length < list.length) { const more = el("button", "Show " + Math.min(MORE, list.length - visible.length) + " more of " + list.length, "more"); more.type = "button"; more.addEventListener("click", () => { expanded[project.id] = (expanded[project.id] || 0) + MORE; render(); }); group.append(more); }
|
|
178
|
+
if (list.length === 0) group.append(el("p", q ? "No match." : "Nothing here.", "empty"));
|
|
179
|
+
shown += visible.length;
|
|
180
|
+
}
|
|
181
|
+
if (starters.has(project.id)) group.append(starter(project));
|
|
182
|
+
groupsEl.append(group);
|
|
183
|
+
}
|
|
184
|
+
if (!groupsEl.children.length) groupsEl.append(el("p", view === "attention" ? "Nothing needs you right now." : "No sessions.", "empty"));
|
|
185
|
+
for (const b of document.querySelectorAll("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === prefs.view));
|
|
186
|
+
metaEl.textContent = shown + " shown · " + attention + " need you · " + sessions.length + " total";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function load() {
|
|
190
|
+
try {
|
|
191
|
+
errorEl.hidden = true;
|
|
192
|
+
const [p, s] = await Promise.all([tp.invoke("projects.list"), fetchAll()]);
|
|
193
|
+
projects = p.projects; sessions = s; render();
|
|
194
|
+
} catch (e) { fail((e.code ? e.code + ": " : "") + e.message); }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const b of document.querySelectorAll("[data-view]")) b.addEventListener("click", () => { prefs.view = b.dataset.view; savePrefs(); render(); });
|
|
198
|
+
$("[data-refresh]").addEventListener("click", () => load());
|
|
199
|
+
searchEl.addEventListener("input", render);
|
|
200
|
+
document.addEventListener("keydown", (e) => {
|
|
201
|
+
if (e.key === "/" && document.activeElement !== searchEl && !/^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName)) { e.preventDefault(); searchEl.focus(); }
|
|
202
|
+
if (e.key === "Escape" && document.activeElement === searchEl) { searchEl.value = ""; render(); searchEl.blur(); }
|
|
203
|
+
});
|
|
204
|
+
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") load(); });
|
|
205
|
+
|
|
206
|
+
await loadPrefs();
|
|
207
|
+
await load();
|
|
208
|
+
tp.watch("session.activity", { limit: 1 }, () => load(), { intervalMs: 15000 });
|
|
209
|
+
})();
|
|
210
|
+
</script>`;
|
|
211
|
+
|
|
212
|
+
/** The starter hub indented for a Markdown code block. */
|
|
213
|
+
export function starterHubForGuide(): string {
|
|
214
|
+
return STARTER_HUB_MAIN.split("\n")
|
|
215
|
+
.map((line) => (line.length ? ` ${line}` : ""))
|
|
216
|
+
.join("\n");
|
|
217
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SessionState } from "../domain/capabilities/specs.ts";
|
|
2
|
+
import type { ActivityItem } from "../host/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bb's thread vocabulary → the product's session state. RW-6
|
|
6
|
+
*
|
|
7
|
+
* active, starting, provisioning, stopping → working
|
|
8
|
+
* error → failed
|
|
9
|
+
* a pending interaction → waiting
|
|
10
|
+
* everything else → idle
|
|
11
|
+
*/
|
|
12
|
+
export function sessionStateOf(thread: { status?: unknown; runtime?: unknown }, hasPendingInteraction: boolean): SessionState {
|
|
13
|
+
const runtime = asRecord(thread.runtime);
|
|
14
|
+
const display = typeof runtime?.displayStatus === "string" ? runtime.displayStatus : typeof thread.status === "string" ? thread.status : "idle";
|
|
15
|
+
if (["active", "starting", "provisioning", "stopping"].includes(display)) return "working";
|
|
16
|
+
if (display === "error") return "failed";
|
|
17
|
+
if (hasPendingInteraction) return "waiting";
|
|
18
|
+
return "idle";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FALLBACK_LABELS: Record<string, readonly [string, string]> = {
|
|
22
|
+
agentMessage: ["Writing", "Wrote"],
|
|
23
|
+
reasoning: ["Thinking", "Thought"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Thread events (`item/started`, `item/completed`) → presented activity, newest last. */
|
|
27
|
+
export function activityItemsOf(events: readonly unknown[], limit: number): ActivityItem[] {
|
|
28
|
+
const out: ActivityItem[] = [];
|
|
29
|
+
for (const raw of events) {
|
|
30
|
+
const event = asRecord(raw);
|
|
31
|
+
if (!event) continue;
|
|
32
|
+
if (event.type !== "item/started" && event.type !== "item/completed") continue;
|
|
33
|
+
const done = event.type === "item/completed";
|
|
34
|
+
const data = asRecord(event.data);
|
|
35
|
+
const item = asRecord(data?.item) ?? data;
|
|
36
|
+
if (!item || typeof item.type !== "string") continue;
|
|
37
|
+
const presentation = asRecord(item.presentation);
|
|
38
|
+
const labels = asRecord(presentation?.label);
|
|
39
|
+
const presented = labels?.[done ? "completed" : "pending"];
|
|
40
|
+
const fallback = FALLBACK_LABELS[item.type];
|
|
41
|
+
const label = typeof presented === "string" ? presented : fallback ? fallback[done ? 1 : 0] : null;
|
|
42
|
+
if (!label) continue;
|
|
43
|
+
const detail = presentation?.title ?? item.command ?? item.text ?? item.name ?? "";
|
|
44
|
+
const atMs = typeof event.createdAt === "number" && Number.isFinite(event.createdAt) ? Math.max(0, Math.trunc(event.createdAt)) : 0;
|
|
45
|
+
out.push({
|
|
46
|
+
kind: item.type.slice(0, 80),
|
|
47
|
+
done,
|
|
48
|
+
atMs,
|
|
49
|
+
label: label.trim().slice(0, 80) || (done ? "Completed" : "Working"),
|
|
50
|
+
text: String(detail).replace(/\s+/g, " ").trim().slice(0, 200),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
out.reverse();
|
|
54
|
+
return out.slice(-limit);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
58
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
59
|
+
}
|