From 91112fc79f312606d577291199b5b4808c3c6487 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Tue, 23 Jun 2026 23:04:05 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20split=20surfaces=20=E2=80=94=20read-onl?= =?UTF-8?q?y=20board=20+=20util-bar/=E2=8C=98K=20file-an-issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the console surfaces per the intended UX: the board is a VIEWER and filing moves to the util bar + the command palette. - view.py: PAGE is now the READ-ONLY board (Issues/PRs tabs + repo picker + state filter; the New-issue form is gone). New NEW_ISSUE_PAGE = a compact file-an-issue form (title/kind/repo/body → the gate-checked POST /issue). - api.py: the view router also serves /new-issue. - manifest: two views — the board (`placement: right` + `palette: inline` ⌘K morph) and `github-new-issue` (a util-bar widget via `utility: {info}` AND a distinct ⌘K page via `palette: {path}`). So Issues/PRs are a quick view (dock + ⌘K) and posting an issue is in the util bar + ⌘K — not in the board. - tests: board-is-read-only (no POST /issue), /new-issue served, both pages hit the gated routes, and the manifest declares both views with the right utility/palette. Co-Authored-By: Claude Opus 4.8 (1M context) --- PROTO.md | 23 +++--- api.py | 9 ++- protoagent.plugin.yaml | 13 ++-- tests/test_board_view.py | 39 ++++++++++- view.py | 146 +++++++++++++++++++++++---------------- 5 files changed, 152 insertions(+), 78 deletions(-) diff --git a/PROTO.md b/PROTO.md index 7de5026..1e27f49 100644 --- a/PROTO.md +++ b/PROTO.md @@ -37,19 +37,24 @@ gh_cli.py # vendored async `gh` runner (run_gh, check_gh_error, b read_tools.py # 8 read tools (6 ported core + read_file/repo_contents) write_tools.py # 8 write tools (create/edit/merge/close/comment/labels/assignees) — gated gh_issue.py # /issue chat command logic (user-only; gate-checked; configured repo) -api.py # board view routers — public PAGE + gated data routes (config/issues/prs/issue) -view.py # board PAGE (HTML): two tabs (Issues/PRs) + repo picker + new-issue form, --pl-* themed +api.py # routers — public PAGES (view/new-issue) + gated data routes (config/issues/prs/issue) +view.py # PAGE = read-only board (Issues/PRs tabs + repo picker); NEW_ISSUE_PAGE = file-an-issue form. --pl-* themed tests/ # host-free pytest (gating + version coherence + routes via TestClient) ``` -**Console board view (ADR 0026/0038/0042).** `register()` mounts two routers when the -host exposes `register_router` (guarded — degrade-safe): the PAGE on the PUBLIC +**Console surfaces (ADR 0026/0038/0042/0057).** `register()` mounts two routers when the +host exposes `register_router` (guarded — degrade-safe): the PAGES on the PUBLIC `/plugins/github` prefix (iframe-loadable; a page-load can't carry a bearer) and the -DATA routes on the GATED `/api/plugins/github` prefix (the page fetches them with the -DS plugin-kit's `apiFetch`, which attaches the operator bearer from the postMessage -handshake). The page is vanilla JS themed entirely from `--pl-*` tokens (no host build). -The manifest's `views:` entry points the console at `/plugins/github/view`. `POST /issue` -reuses the SAME `file_issue` gate path as the `/issue` command, so they can't diverge. +DATA routes on the GATED `/api/plugins/github` prefix (pages fetch them with the DS +plugin-kit's `apiFetch`, which attaches the operator bearer from the postMessage +handshake). Pages are vanilla JS themed entirely from `--pl-*` tokens (no host build). +Two manifest `views`, three surfaces, deliberately split: +- **`/view`** — the READ-ONLY board (Issues/PRs tabs + repo picker + state filter). Right + dock + a ⌘K morph (`palette: inline`). No writes — it's a viewer. +- **`/new-issue`** — the compact file-an-issue form. A util-bar widget pill (`utility`, + opens its dialog) AND a distinct ⌘K page (`palette: { path }`). `POST /issue` reuses the + SAME `file_issue` gate path as the `/issue` command, so they can't diverge. +Filing lives in the widget + palette, NOT the board (keep the board read-only). ## 4. The gating design — DO NOT BREAK IT diff --git a/api.py b/api.py index 63d8f57..5c59ebd 100644 --- a/api.py +++ b/api.py @@ -85,11 +85,12 @@ def _repos(cfg: dict) -> list[str]: def build_view_router(): - """The board PAGE — served under the PUBLIC ``/plugins/github`` prefix (ungated).""" + """The PAGES — served under the PUBLIC ``/plugins/github`` prefix (ungated): the + read-only board (``/view``) and the compact file-an-issue form (``/new-issue``).""" from fastapi import APIRouter from fastapi.responses import HTMLResponse - from .view import PAGE + from .view import NEW_ISSUE_PAGE, PAGE router = APIRouter() @@ -97,6 +98,10 @@ def build_view_router(): async def _view(): return HTMLResponse(PAGE) + @router.get("/new-issue") + async def _new_issue(): + return HTMLResponse(NEW_ISSUE_PAGE) + return router diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 4361758..ca3a107 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -31,11 +31,16 @@ settings: - {key: default_repo, label: "Default repo (owner/name)", type: string} - {key: repos, label: "Repo picker list", type: string_list} -# Console board view (ADR 0026) — a sandboxed iframe the plugin serves itself at -# /plugins/github/view (api.py). Two tabs (Issues / PRs) over the repo picker above. -# `placement: right` opens it in the right dock; views are drag-sortable in the console. +# Console views (ADR 0026/0057) — sandboxed iframes the plugin serves itself (api.py). +# - the READ-ONLY board (Issues/PRs tabs over the repo picker): right dock + a ⌘K morph +# (`palette: inline` opens the board in the palette body). `placement: right` is just a +# default — views are drag-sortable in the console. +# - FILE AN ISSUE (the compact form): a util-bar widget pill (`utility`, opens its dialog) +# AND a distinct ⌘K palette page (`palette: { path }`). Filing lives here + the palette, +# NOT in the board — the board is a viewer. views: - - { id: github, label: GitHub, icon: Github, path: /plugins/github/view, placement: right } + - { id: github, label: GitHub, icon: Github, path: /plugins/github/view, placement: right, palette: inline } + - { id: github-new-issue, label: New issue, icon: Bug, path: /plugins/github/new-issue, utility: { info: "File a GitHub issue" }, palette: { path: /plugins/github/new-issue } } # Auth: `gh` uses its own ambient auth (`gh auth login`), or GITHUB_TOKEN / GH_TOKEN # from the env if set. No plugin secret needed for public-repo reads. diff --git a/tests/test_board_view.py b/tests/test_board_view.py index dc70a5e..ce20285 100644 --- a/tests/test_board_view.py +++ b/tests/test_board_view.py @@ -74,6 +74,21 @@ def test_view_page_served(): assert r.status_code == 200 and "GitHub" in r.text +def test_new_issue_page_served(): + r = TestClient(_app()).get("/plugins/github/new-issue") + assert r.status_code == 200 and "New issue" in r.text + + +def test_board_page_is_read_only(): + """The board is a viewer — it must NOT POST to /issue (filing lives in the widget/palette).""" + from ghplugin.view import PAGE + + # The board must not POST to the create-issue endpoint. Match it exactly with its + # closing quote, since "/api/plugins/github/issue" is a substring of ".../issues". + assert '/api/plugins/github/issue"' not in PAGE # no create-issue from the board + assert "/api/plugins/github/issues" in PAGE and "/api/plugins/github/prs" in PAGE # reads only + + def test_config_route_returns_repos_and_default(): c = TestClient(_app()) body = c.get("/api/plugins/github/config").json() @@ -138,10 +153,30 @@ def test_legacy_host_without_register_router_still_loads_tools(make_legacy_regis def test_page_references_gated_endpoints(): - """The page fetches its DATA from the gated /api/plugins/github routes.""" - from ghplugin.view import PAGE + """The pages fetch their DATA from the gated /api/plugins/github routes.""" + from ghplugin.view import NEW_ISSUE_PAGE, PAGE assert "/api/plugins/github/config" in PAGE assert "/api/plugins/github/issues" in PAGE assert "/api/plugins/github/prs" in PAGE assert "/_ds/plugin-kit" in PAGE # themed via the DS kit + # The new-issue page posts to the gated create route + is kit-themed. + assert "/api/plugins/github/issue" in NEW_ISSUE_PAGE + assert "/_ds/plugin-kit" in NEW_ISSUE_PAGE + + +def test_manifest_declares_board_and_widget_views(): + """Two views: the read-only board (right dock + ⌘K) and the file-an-issue widget + (util-bar pill + ⌘K palette page).""" + from pathlib import Path + + import yaml + + root = Path(__file__).resolve().parent.parent + views = {v["id"]: v for v in yaml.safe_load((root / "protoagent.plugin.yaml").read_text())["views"]} + board, new_issue = views["github"], views["github-new-issue"] + assert board["placement"] == "right" and board["palette"] == "inline" + assert board["path"] == "/plugins/github/view" + assert new_issue["utility"]["info"] # a util-bar pill with hover info + assert new_issue["palette"]["path"] == "/plugins/github/new-issue" # distinct ⌘K page + assert new_issue["path"] == "/plugins/github/new-issue" diff --git a/view.py b/view.py index ff1588f..9b2cdb5 100644 --- a/view.py +++ b/view.py @@ -1,33 +1,34 @@ -"""The GitHub board console view — a self-contained page served at -``/plugins/github/view`` (by api.py's view router) and iframed by the console. +"""The GitHub console pages — served at ``/plugins/github/*`` (by api.py) and iframed +by the console. Two pages, two surfaces: -A quick read of a project's board: two tabs (Issues / Pull Requests) over a repo -picker sourced from the plugin's configured ``github.repos`` (the same registration -the ``/issue`` command uses), an open/closed/all state filter, and a "New issue" form -that posts through the SAME gate-checked path as ``/issue``. +- ``PAGE`` (``/view``) — the **read-only board**: a quick look at a project's board, + two tabs (Issues / Pull Requests) over a repo picker (the configured ``github.repos``) + + an open/closed/all filter. No writes — it's a viewer. Declared in the manifest as a + right-dock view AND a ⌘K palette morph (``palette: inline``). +- ``NEW_ISSUE_PAGE`` (``/new-issue``) — the compact **file-an-issue** form (title / kind / + repo / body → the gate-checked ``POST /issue``). Declared as a util-bar widget + (``utility``) AND a distinct ⌘K palette page (``palette: { path }``) — so filing lives + in the util bar + the palette, NOT in the board (the "view is read-only" split). -FOUR-RULES COMPLIANT (docs/how-to/build-a-plugin-view.md, the notes/chat_example -pattern): served on the PUBLIC path · DATA is the gated channel (the DS kit's -``apiFetch`` attaches the operator bearer from the postMessage handshake) · slug-aware -base (host window AND the fleet ``/agents/`` proxy) · links the DS plugin-kit so -the whole page is themed from the operator's live ``--pl-*`` tokens (no host build — -vanilla JS, git-installable per ADR 0038). +FOUR-RULES COMPLIANT (docs/how-to/build-a-plugin-view.md): served on the PUBLIC path; +DATA is the gated channel (the DS kit's ``apiFetch`` attaches the operator bearer from +the postMessage handshake); slug-aware base (host window AND the fleet proxy); links the +DS plugin-kit so the page is themed from the operator's live ``--pl-*`` tokens. Vanilla +JS, no host build (ADR 0038). """ from __future__ import annotations +# --- the read-only board ----------------------------------------------------- PAGE = r""" GitHub
@@ -69,38 +63,22 @@
-
-
-
- - -
- -
- - - -
-
Loading…
""" + + +# --- the compact "file an issue" page (util-bar widget + ⌘K palette) --------- +NEW_ISSUE_PAGE = r""" + +New issue + + +
+
+ + +
+ + +
+ + + +
+
+"""