-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathboard_view.py
More file actions
214 lines (200 loc) · 12.1 KB
/
Copy pathboard_view.py
File metadata and controls
214 lines (200 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
"""Board console view (ADR 0026, D5) — the Kanban/list board UI.
A self-contained page served at ``/plugins/project_board/board`` (by the API
router in api.py — see __init__.register) that renders the board two ways (Kanban
columns = the 6 states, and a dense list), toggled — two projections of the same
features over the already-proven ``/features`` API. The console renders a left-rail
icon (manifest ``views:``) whose panel iframes this page; on load the console
``postMessage``s a bearer token + theme tokens (the ADR 0026 handshake), which the
page applies for its same-origin API calls.
FLEET-PROXY-SAFE (ADR 0042): the iframe loads at /plugins/project_board/board on
the host window, but at /agents/<slug>/plugins/project_board/board when this
agent is viewed through the fleet proxy. So the page derives ``base`` from its own
path (= "" on host, "/agents/<slug>" when proxied) and prefixes EVERY fetch + asset
with it — never hardcode an absolute "/api/...", "/plugins/...", or
"http://localhost:PORT" (that breaks the proxy + the same-origin postMessage token).
No build step — vanilla JS + inline SVG, so the whole plugin stays a drop-in
package. The page reads the board; it never mutates it (mutation stays the loop +
the tools + the API). ``BOARD_PAGE`` is the HTML; api.py serves it on GET /board.
"""
from __future__ import annotations
BOARD_PAGE = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Board</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--pl-color-bg);color:var(--pl-color-fg);
font-family:var(--pl-font-sans);font-size:13px}
.wrap{max-width:1240px;margin:0 auto;padding:var(--pl-space-4) var(--pl-space-6)}
.top{display:flex;align-items:center;gap:var(--pl-space-3);margin-bottom:var(--pl-space-4)}
h1{font-size:17px;margin:0;color:var(--pl-color-accent);letter-spacing:.2px}
.sub{color:var(--pl-color-fg-muted);font-size:12px;margin:0;flex:1}
/* Kanban — no DS primitive for cards/columns; token-driven. */
.board{display:grid;grid-template-columns:repeat(5,1fr);gap:var(--pl-space-3)}
.col{background:var(--pl-color-bg-raised);border:var(--pl-border-width) solid var(--pl-color-border);
border-radius:var(--pl-radius);padding:var(--pl-space-2);min-height:120px}
.col .pl-panel-header{padding:2px 4px var(--pl-space-2);text-transform:uppercase;
letter-spacing:.06em;font-size:11px}
.card{background:var(--pl-color-bg-raised);border:var(--pl-border-width) solid var(--pl-color-border);
border-left:3px solid var(--pl-color-accent);
border-radius:var(--pl-radius);padding:9px 10px;margin-bottom:var(--pl-space-2)}
.card .t{font-size:12.5px;line-height:1.35;margin-bottom:6px}
.card .m{display:flex;gap:var(--pl-space-2);align-items:center;flex-wrap:wrap;font-size:10.5px;color:var(--pl-color-fg-muted)}
.id{font-family:var(--pl-font-mono);font-size:10px}
a.pr{color:var(--pl-color-status-info);text-decoration:none}
a.pr:hover{text-decoration:underline}
.hide{display:none}
/* List view — collapsible per-state group headers (#26). Token-driven, mirrors the
Kanban column grouping as sections in the dense list. */
#list tr.grp{cursor:pointer;user-select:none}
#list tr.grp>td{background:var(--pl-color-bg-raised);text-transform:uppercase;
letter-spacing:.06em;font-size:11px;font-weight:600;color:var(--pl-color-fg-muted);
padding:var(--pl-space-2) 4px}
#list tr.grp:hover>td{color:var(--pl-color-fg)}
#list tr.grp .tw{display:inline-block;width:1.1em;text-align:center}
#list tr.grp .gl{margin-right:var(--pl-space-2)}
/* Narrow/mobile: the JS auto-switches to the list; if Kanban is forced, stack it. */
@media (max-width:760px){ .board{grid-template-columns:1fr} .wrap{padding:var(--pl-space-3)} }
</style>
<script>
// ── Slug-aware base (ADR 0042): "" on the host window, "/agents/<slug>" when proxied.
// Split on the prefix this page is served under so EVERY asset + fetch stays
// same-origin and reaches THIS agent (never the host) through the fleet proxy.
var BASE = location.pathname.split("/plugins/")[0];
// Link the DS kit same-origin off BASE (rule 4) — --pl-* tokens, never hardcode hex.
(function(){ var l=document.createElement("link"); l.rel="stylesheet";
l.href=BASE+"/_ds/plugin-kit.css"; document.head.appendChild(l); })();
</script>
</head><body><div class="wrap">
<div class="top">
<h1>Board</h1>
<p class="sub" id="sub">project_board — a projection over beads</p>
<div class="pl-tabs">
<button id="tk" class="pl-tab pl-tab--active" onclick="setView('kanban')">Kanban</button>
<button id="tl" class="pl-tab" onclick="setView('list')">List</button>
</div>
</div>
<div id="err" class="pl-callout pl-callout--error" hidden></div>
<div id="kanban" class="board"></div>
<table id="list" class="pl-table hide"><thead><tr>
<th>ID</th><th>Title</th><th>State</th><th>Pri</th><th>Flags</th><th>PR</th></tr></thead>
<tbody id="rows"></tbody></table>
</div>
<script type="module">
// The DS plugin-kit owns the protoagent:init handshake (bearer + theme, incl. live
// re-themes onto the --pl-* tokens) and slug-aware authed fetches — replacing the
// hand-rolled TMAP/listener this page carried. plugin-kit.js is an ES MODULE, so it
// loads via dynamic import (a classic <script src> throws on its exports; see
// protoAgent docs/how-to/build-a-plugin-view.md). Older host without /_ds: fall
// back to a tokenless same-origin shim.
let kit;
try { kit = await import(BASE + "/_ds/plugin-kit.js"); }
catch (e) { kit = { initPluginView(){}, apiFetch: (p, i) => fetch(BASE + p, i) }; }
const COLS = ["backlog", "ready", "in_progress", "in_review", "done"];
// State → DS status token. (blocked → error, dag/deps → warning handled in flags.)
const STATE_COLOR = {backlog:"var(--pl-color-fg-muted)", ready:"var(--pl-color-status-success)",
in_progress:"var(--pl-color-accent)", in_review:"var(--pl-color-status-info)",
done:"var(--pl-color-fg-muted)", blocked:"var(--pl-color-status-error)"};
// Slug-aware authed fetch via the kit (rules 2+3) — pass a bare /api/... path.
// Errors become READABLE: a JSON error body surfaces its `detail` (the actionable
// BoardError message), a non-JSON body its HTTP status — never a raw parse error.
const api = async (p) => {
const r = await kit.apiFetch(p);
const d = await r.json().catch(() => { throw new Error("HTTP " + r.status + " (non-JSON response)"); });
if (!r.ok) throw new Error(d.detail || "HTTP " + r.status);
return d;
};
const $ = (id) => document.getElementById(id);
const esc = (s) => (s||"").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c]));
// Narrow viewport → the 5-column Kanban doesn't fit, so default to the list.
const NARROW = window.matchMedia("(max-width: 760px)");
let VIEW = NARROW.matches ? "list" : "kanban";
function setView(v){ VIEW=v; $("tk").classList.toggle("pl-tab--active",v==="kanban"); $("tl").classList.toggle("pl-tab--active",v==="list");
$("kanban").classList.toggle("hide",v!=="kanban"); $("list").classList.toggle("hide",v!=="list"); render(); }
// Auto-switch to list when the viewport narrows (e.g. rotating a phone / resizing).
NARROW.addEventListener("change", (e) => { if (e.matches && VIEW !== "list") setView("list"); });
let FEATURES = [];
function flags(f){
let out = "";
if (f.blocked) out += '<span class="pl-badge pl-badge--error">blocked</span>';
if (f.dag_blocked) out += '<span class="pl-badge pl-badge--warning">waiting on deps</span>';
if (f.difficulty) out += '<span class="pl-badge">'+esc(f.difficulty)+'</span>';
return out;
}
function pr(f){ return f.pr_url ? '<a class="pr" href="'+esc(f.pr_url)+'" target="_blank">PR ↗</a>' : ""; }
// State → DS dot variant (for the list view chip).
const DOT_VARIANT = {ready:"pl-dot--success", in_review:"pl-dot--info", blocked:"pl-dot--error"};
// List view sections: the Kanban columns + the `blocked` flag-state + `cancelled`
// (the second terminal edge, #47), rendered as collapsible groups in COLS order (#26).
const LIST_SECTIONS = [...COLS, "blocked", "cancelled"];
// States the user has collapsed — module-scoped so the 10s auto-reload re-render
// doesn't re-expand what they closed.
const COLLAPSED = new Set();
function toggleGroup(state){ COLLAPSED.has(state) ? COLLAPSED.delete(state) : COLLAPSED.add(state); render(); }
function render(){
if (VIEW === "kanban"){
$("kanban").innerHTML = COLS.map(state => {
const items = FEATURES.filter(f => f.state === state);
const cards = items.map(f => {
const color = f.blocked ? "var(--pl-color-status-error)" : (f.dag_blocked ? "var(--pl-color-status-warning)" : (STATE_COLOR[state]||"var(--pl-color-accent)"));
return '<div class="card" style="border-left-color:'+color+'">'
+ '<div class="t">'+esc(f.title)+'</div>'
+ '<div class="m"><span class="id">'+esc(f.id)+'</span><span>P'+f.priority+'</span>'
+ flags(f)+' '+pr(f)+'</div></div>';
}).join("") || '<div class="pl-empty">—</div>';
return '<div class="col"><div class="pl-panel-header pl-panel-header--compact">'
+ '<span class="pl-panel-header__title">'+state.replace("_"," ")+'</span>'
+ '<span class="pl-badge">'+items.length+'</span></div>'+cards+'</div>';
}).join("");
} else {
// List: group rows under a collapsible per-state header (COLS order + blocked +
// cancelled), mirroring the Kanban's grouping so a dense board stays scannable (#26).
const row = (f) =>
'<tr><td class="id">'+esc(f.id)+'</td><td>'+esc(f.title)+'</td>'
+ '<td><span class="pl-dot-row"><span class="pl-dot '+(DOT_VARIANT[f.state]||"")+'"></span>'
+ '<span class="pl-dot-row__label">'+esc(f.state)+'</span></span></td>'
+ '<td>P'+f.priority+'</td><td>'+flags(f)+'</td><td>'+pr(f)+'</td></tr>';
const byState = {};
FEATURES.forEach(f => (byState[f.state] = byState[f.state] || []).push(f));
// COLS order + blocked + cancelled; any unexpected state lands in its own group last.
const order = LIST_SECTIONS.slice();
Object.keys(byState).forEach(s => { if (!order.includes(s)) order.push(s); });
let html = "";
order.forEach(state => {
const items = byState[state] || [];
if (!items.length) return; // omit empty sections
const collapsed = COLLAPSED.has(state);
html += '<tr class="grp" data-state="'+esc(state)+'" onclick="toggleGroup(this.dataset.state)">'
+ '<td colspan="6"><span class="tw">'+(collapsed?"▸":"▾")+'</span>'
+ '<span class="gl">'+esc(state.replace("_"," "))+'</span>'
+ '<span class="pl-badge">'+items.length+'</span></td></tr>';
if (!collapsed) html += items.map(row).join(""); // collapsed → header only (rows omitted)
});
$("rows").innerHTML = html || '<tr><td colspan="6"><div class="pl-empty">No features yet — create some via the board tools or API.</div></td></tr>';
}
}
async function load(){
try {
const r = await api("/api/plugins/project_board/features");
// the /features API field is `board_state`; normalize to `state` for the views.
FEATURES = (r.features || []).map(f => ({...f, state: f.board_state ?? f.state}))
.sort((a,b) => a.priority - b.priority || a.id.localeCompare(b.id));
$("err").hidden = true;
$("sub").textContent = "project_board — " + FEATURES.length + " features · a projection over beads";
render();
} catch (e) {
$("err").hidden = false; $("err").textContent = "Could not load the board: " + e;
}
}
// Module scripts are scoped — expose the inline onclick handlers (view toggle +
// the list's per-state collapse).
window.setView = setView;
window.toggleGroup = toggleGroup;
setView(VIEW); // sync the toggle + visibility to the initial view (list on mobile)
// Boot ONCE, on whichever fires first: the handshake (the bearer arrives with
// protoagent:init, so the gated /features pull authenticates) or a short timer
// for the no-handshake case (standalone page / older host).
let booted = false;
function boot(){ if (booted) return; booted = true; load(); setInterval(load, 10000); }
kit.initPluginView(boot);
setTimeout(boot, 800);
</script></body></html>"""