diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 7c03672..6b5335d 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -2,7 +2,7 @@ # Keep `version` in lockstep with pyproject.toml (tests/test_version.py asserts it). id: github name: GitHub (read/write tools) -version: 0.1.3 +version: 0.1.4 description: >- Read AND write GitHub tools over the `gh` CLI, with PER-AGENT write gating. The read tools (PRs, issues, diffs, CI, repo files/contents) are always on; the write diff --git a/pyproject.toml b/pyproject.toml index cf2fc98..8fb379d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "github-plugin" -version = "0.1.3" +version = "0.1.4" description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating." requires-python = ">=3.11" diff --git a/tests/test_board_view.py b/tests/test_board_view.py index 8440efa..b2801e3 100644 --- a/tests/test_board_view.py +++ b/tests/test_board_view.py @@ -99,6 +99,38 @@ def test_pages_boot_once(): assert "boot();" not in page # …and NOT also called directly +def test_pages_boot_is_idempotent(): + """#15 — the kit's ``initPluginView`` callback fires on the initial init AND on every + re-theme (plus the handshake re-send), so ``boot`` must guard itself to run exactly once. + 'Boot once' at the call site (#13) wasn't enough — the single callback fires repeatedly, + each run rebuilding the picker + re-loading → the list flicker/thrash returned.""" + from ghplugin.view import NEW_ISSUE_PAGE, PAGE + + for page in (PAGE, NEW_ISSUE_PAGE): + assert "if (booted) return;" in page # the callback is guarded, not just called once + + +def test_board_renders_comment_count_not_the_array(): + """#16 — ``gh issue list --json comments`` returns an ARRAY of comment objects; the board + must render its COUNT (``.length``), not ``String(array)`` which shows '[object Object]'.""" + from ghplugin.view import PAGE + + assert "it.comments.length" in PAGE # the count, not the raw array + assert "esc(it.comments)" not in PAGE # the old object-stringifying render is gone + assert "💬" not in PAGE # emoji swapped for a Lucide icon + assert 'PRs<" in PAGE # tightened tab label + + def test_config_route_returns_repos_and_default(): c = TestClient(_app()) body = c.get("/api/plugins/github/config").json() diff --git a/view.py b/view.py index 31ff2c7..d85d6ea 100644 --- a/view.py +++ b/view.py @@ -33,13 +33,16 @@ html,body{margin:0;height:100%;background:var(--pl-color-bg-raised);color:var(--pl-color-fg); font-family:var(--pl-font-sans,ui-sans-serif,system-ui,sans-serif);font-size:13px} #wrap{display:flex;flex-direction:column;height:100%;min-height:0} - .bar{display:flex;align-items:center;gap:8px;padding:8px 10px;flex-wrap:wrap; + .bar{display:flex;align-items:center;gap:6px;padding:6px 8px;flex-wrap:wrap; border-bottom:var(--pl-border-width,1px) solid var(--pl-color-border)} .tabs{display:inline-flex;border:1px solid var(--pl-color-border);border-radius:var(--pl-radius,8px);overflow:hidden} - .tab{padding:5px 12px;cursor:pointer;background:transparent;border:0;color:var(--pl-color-fg-muted);font-size:12px} + .tab{padding:4px 11px;cursor:pointer;background:transparent;border:0;color:var(--pl-color-fg-muted); + font-size:12px;line-height:1.6;white-space:nowrap} .tab[aria-selected="true"]{background:var(--pl-color-bg);color:var(--pl-color-fg);font-weight:600} select{background:var(--pl-color-bg);color:var(--pl-color-fg);border:1px solid var(--pl-color-border); - border-radius:var(--pl-radius,8px);padding:4px 6px;font-size:12px;max-width:240px} + border-radius:var(--pl-radius,8px);padding:4px 6px;font-size:12px;max-width:220px} + .ico{width:1em;height:1em;flex:none;vertical-align:-0.15em} + #refresh{display:inline-flex;align-items:center;justify-content:center;padding:4px 7px;color:var(--pl-color-fg-muted)} .spacer{flex:1} #list{flex:1;min-height:0;overflow:auto;padding:4px 0} .row{display:block;text-decoration:none;color:inherit;padding:9px 12px; @@ -51,6 +54,7 @@ .title{font-weight:600;line-height:1.35} .num{color:var(--pl-color-fg-muted);font-weight:400} .meta{margin-top:3px;font-size:11px;color:var(--pl-color-fg-muted);display:flex;gap:6px;flex-wrap:wrap;align-items:center} + .cmt{display:inline-flex;align-items:center;gap:3px} .pill{font-size:10px;padding:1px 7px;border-radius:999px;border:1px solid var(--pl-color-border)} .empty,.hint{padding:24px 14px;text-align:center;color:var(--pl-color-fg-muted)} @@ -58,12 +62,12 @@
- - + +
- +
Loading…
@@ -77,17 +81,26 @@ let tab = "issues"; const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&":"&","<":"<",">":">",'"':""" }[c])); const fmtDate = (iso) => { try { return new Date(iso).toLocaleDateString(undefined,{month:"short",day:"numeric",year:"numeric"}); } catch(e){ return ""; } }; + // Inline Lucide (v0.468) SVGs — themed via currentColor, no runtime dep / kit icon API. + const svg = (paths) => ''; + const ICON = { + comment: svg(''), + refresh: svg(''), + }; function setTab(t){ tab = t; $("t-issues").setAttribute("aria-selected", t==="issues"); $("t-prs").setAttribute("aria-selected", t==="prs"); load(); } function labelPills(labels){ return (labels||[]).slice(0,5).map(l => ''+esc(l.name||l)+'').join(" "); } function issueRow(it){ const st = (it.state||"").toLowerCase(); + // `gh issue list --json comments` returns an ARRAY of comment objects, not a count — + // rendering it directly was the "[object Object]" bug (#16). Show its length. + const cc = Array.isArray(it.comments) ? it.comments.length : (Number(it.comments)||0); return '' + '
' + ''+esc(it.title)+' #'+esc(it.number)+'
' + '
'+esc((it.author&&it.author.login)||"?")+''+fmtDate(it.createdAt)+'' - + (it.comments?('💬 '+esc(it.comments)+''):'')+' '+labelPills(it.labels)+'
'; + + (cc?(''+ICON.comment+esc(cc)+''):'')+' '+labelPills(it.labels)+''; } function prRow(it){ const merged = (it.state||"").toLowerCase()==="merged", draft = !!it.isDraft; @@ -122,7 +135,10 @@ } catch(e){ if(my===loadSeq) list.innerHTML = '
Failed to load — is the agent reachable?
'; } } + let booted = false; async function boot(){ + if (booted) return; // the kit re-fires this on every re-theme + the handshake re-send; + booted = true; // build the picker and first-load EXACTLY once, or the list thrashes (#15). let cfg = { repos: [], default_repo: "" }; try { cfg = await kit.apiFetch("/api/plugins/github/config").then(r => r.json()); } catch(e){} const sel = $("repo"); @@ -131,12 +147,14 @@ load(); } + $("refresh").innerHTML = ICON.refresh; $("t-issues").onclick = () => setTab("issues"); $("t-prs").onclick = () => setTab("prs"); $("repo").onchange = load; $("state").onchange = load; $("refresh").onclick = load; - // Boot ONCE — via the kit so it runs after the theme/auth handshake (the fallback kit calls - // it immediately). A second direct boot() here caused two overlapping config+load sequences - // → the list flicker/thrash on mount (#13). + // Boot via the kit so it runs after the theme/auth handshake (the fallback kit calls it + // immediately). initPluginView's callback fires on the initial init AND every re-theme, so + // boot() itself is guarded (booted) to run once — that's what actually kills the mount + // thrash; a second direct boot() call was removed in #13. kit.initPluginView(boot); """ @@ -182,7 +200,10 @@ const $ = (id) => document.getElementById(id); const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&":"&","<":"<",">":">",'"':""" }[c])); + let booted = false; async function boot(){ + if (booted) return; // kit re-fires on every re-theme; populate the picker once so it never + booted = true; // clobbers an in-progress repo selection (#15). let cfg = { repos: [], default_repo: "" }; try { cfg = await kit.apiFetch("/api/plugins/github/config").then(r => r.json()); } catch(e){} $("repo").innerHTML = (cfg.repos||[]).map(r => '').join("");